# Audio_Transcription_Lib.py
#########################################
# Transcription Library
# This library is used to perform transcription of audio files.
# Currently, uses faster_whisper for transcription.
#
####################
# Function List
#
# 1. convert_to_wav(video_file_path, offset=0, overwrite=False)
# 2. speech_to_text(audio_file_path, selected_source_lang='en', whisper_model='small.en', vad_filter=False)
#
####################
#
# Import necessary libraries to run solo for testing
import gc
import glob
import json
import multiprocessing
import os
import shutil
from pathlib import Path
from datetime import datetime, timedelta
import subprocess
import sys
import tempfile
import threading
import time
import queue
from functools import lru_cache
from typing import Optional, Union, List, Dict, Any, Tuple
#
# DEBUG Imports
#from memory_profiler import profile
# Third-Party Imports
from faster_whisper import WhisperModel as OriginalWhisperModel
import numpy as np
import torch
from scipy.io import wavfile
from transformers import AutoProcessor, Qwen2AudioForConditionalGeneration

# Import diarization module (optional dependency)
try:
    from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Diarization_Lib import (
        DiarizationService,
        DiarizationError
    )
    DIARIZATION_AVAILABLE = True
except ImportError:
    DIARIZATION_AVAILABLE = False
    DiarizationService = None
    DiarizationError = Exception  # Fallback to base Exception
#
# Import Local
from tldw_Server_API.app.core.Utils.Utils import sanitize_filename, logging
from tldw_Server_API.app.core.Metrics.metrics_logger import log_counter, log_histogram, timeit
from tldw_Server_API.app.core.config import load_and_log_configs, loaded_config_data, get_stt_config
from typing import Union


#
#######################################################################################################################
# Constants
#

# Get configuration values or use defaults
media_config = loaded_config_data.get('media_processing', {}) if loaded_config_data else {}
AUDIO_TRANSCRIPTION_BUFFER_SIZE_MB = media_config.get('audio_transcription_buffer_size_mb', 10)
"""int: Maximum buffer size for audio transcription in MB."""

# Transcript cache and STT settings (env overrides config)
def _stt_cache_config():
    """
    Return the `[STT-Settings]` section from the loaded config (if any).

    This helper is shared by transcript-cache and STT-related toggles so that
    all STT knobs live under a single INI section.
    """
    try:
        return get_stt_config()
    except Exception:
        logging.debug("Failed to load STT cache config, using empty dict")
        return {}


_cache_cfg = _stt_cache_config()


def _to_bool(val) -> bool:
    return str(val).lower() in {"1", "true", "yes", "on"}


_env_disable = _to_bool(os.getenv("STT_DISABLE_TRANSCRIPT_CACHE", ""))
_cfg_disable = _cache_cfg.get("disable_transcript_cache", False)
PERSIST_TRANSCRIPTS_DEFAULT = not (_to_bool(_cfg_disable) or _env_disable)


def _coerce_int(val):
    try:
        return int(val)
    except Exception:
        return None


def _coerce_float(val):
    try:
        return float(val)
    except Exception:
        return None


# Conservative defaults to prevent unbounded cache growth unless explicitly
# disabled via `disable_transcript_cache_pruning` or environment variables.
DEFAULT_CACHE_MAX_FILES_PER_SOURCE = 32
DEFAULT_CACHE_MAX_AGE_DAYS = 30
DEFAULT_CACHE_MAX_TOTAL_MB = 512.0

_raw_max_files = os.getenv("STT_CACHE_MAX_FILES_PER_SOURCE") or _cache_cfg.get(
    "transcript_cache_max_files_per_source"
)
_raw_max_age = os.getenv("STT_CACHE_MAX_AGE_DAYS") or _cache_cfg.get(
    "transcript_cache_max_age_days"
)
_raw_max_total = os.getenv("STT_CACHE_MAX_TOTAL_MB") or _cache_cfg.get(
    "transcript_cache_max_total_mb"
)

_max_files_val = _coerce_int(_raw_max_files)
_max_age_val = _coerce_int(_raw_max_age)
_max_total_val = _coerce_float(_raw_max_total)

# When values are absent/invalid (None), fall back to conservative defaults;
# explicit 0 or negative values are preserved so callers can disable individual
# limits if desired.
CACHE_MAX_FILES_PER_SOURCE = (
    DEFAULT_CACHE_MAX_FILES_PER_SOURCE if _max_files_val is None else _max_files_val
)
CACHE_MAX_AGE_DAYS = (
    DEFAULT_CACHE_MAX_AGE_DAYS if _max_age_val is None else _max_age_val
)
CACHE_MAX_TOTAL_MB = (
    DEFAULT_CACHE_MAX_TOTAL_MB if _max_total_val is None else _max_total_val
)

_env_skip_prevalidation = _to_bool(os.getenv("STT_SKIP_AUDIO_PREVALIDATION", ""))
_cfg_skip_prevalidation = _cache_cfg.get("skip_audio_prevalidation", False)
SKIP_AUDIO_PREVALIDATION = _to_bool(_cfg_skip_prevalidation) or _env_skip_prevalidation

_env_disable_prune = _to_bool(os.getenv("STT_DISABLE_TRANSCRIPT_CACHE_PRUNING", ""))
_cfg_disable_prune = _cache_cfg.get("disable_transcript_cache_pruning", False)
DISABLE_TRANSCRIPT_CACHE_PRUNING = _to_bool(_cfg_disable_prune) or _env_disable_prune

# Optional faster-whisper compute_type override. When unset or equal to "auto",
# Whisper models default to float16 on CUDA and int8 on CPU; when set to a
# supported faster-whisper compute_type (e.g. "int8", "int8_float16"), that
# value is passed through to the underlying WhisperModel.
WHISPER_COMPUTE_TYPE_OVERRIDE = str(
    _cache_cfg.get("whisper_compute_type", "")
).strip().lower()

def _resample_audio_if_needed(audio: np.ndarray, sample_rate: int, target_sr: int = 16000) -> np.ndarray:
    """
    Return audio at the target sample rate, resampling if necessary.

    Uses librosa when available; falls back to a simple linear interpolation so we
    can avoid a hard dependency. Always returns float32.
    """
    if sample_rate == target_sr:
        return audio.astype(np.float32, copy=False)
    try:
        import librosa
        return librosa.resample(audio, orig_sr=sample_rate, target_sr=target_sr).astype(np.float32, copy=False)
    except Exception as e:
        logging.debug(f"Falling back to naive resample: {e}")
        ratio = float(target_sr) / float(sample_rate)
        new_len = max(1, int(round(len(audio) * ratio)))
        # Linear interpolation fallback
        x_old = np.linspace(0.0, 1.0, num=len(audio), endpoint=False)
        x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
        return np.interp(x_new, x_old, audio).astype(np.float32, copy=False)


def _resolve_project_root() -> Path:
    """Return the repository root to anchor shared model directories."""
    current = Path(__file__).resolve()
    for parent in current.parents:
        models_dir = parent / "models"
        if models_dir.is_dir():
            return parent

    parents = current.parents
    fallback_index = 5 if len(parents) > 5 else len(parents) - 1
    return parents[fallback_index]


PROJECT_ROOT_DIR = _resolve_project_root()
WHISPER_MODEL_BASE_DIR = (PROJECT_ROOT_DIR / "models" / "Whisper").resolve()

_AUDIO_VALIDATION_CACHE: Dict[str, tuple] = {}
_PRUNE_DISABLED_LOGGED: bool = False

def prune_transcript_cache(
    cache_dir: Path,
    *,
    current_file: Optional[Path] = None,
    max_age_days: Optional[int] = CACHE_MAX_AGE_DAYS,
    max_total_mb: Optional[float] = CACHE_MAX_TOTAL_MB,
    max_files_per_source: Optional[int] = CACHE_MAX_FILES_PER_SOURCE,
) -> None:
    """
    Prune cached transcript files by age, total size, and per-source limits.

    Args:
        cache_dir: Directory containing transcript cache files.
        current_file: File that was just written; always preserved.
        max_age_days: Delete files older than this many days (None disables).
        max_total_mb: Delete oldest files until total size is under limit (None disables).
        max_files_per_source: Keep only the newest N files per base source (None disables).
    """
    if DISABLE_TRANSCRIPT_CACHE_PRUNING:
        global _PRUNE_DISABLED_LOGGED
        if not _PRUNE_DISABLED_LOGGED:
            _PRUNE_DISABLED_LOGGED = True
            logging.info(
                "Transcript cache pruning is disabled via STT-Settings.disable_transcript_cache_pruning "
                "or STT_DISABLE_TRANSCRIPT_CACHE_PRUNING; cached transcripts may grow without bound."
            )
        return
    try:
        cache_dir.mkdir(parents=True, exist_ok=True)
    except Exception:
        return

    files = sorted(
        cache_dir.glob("*-whisper_model-*.segments*.json"),
        key=lambda p: p.stat().st_mtime if p.exists() else 0,
        reverse=True,
    )
    if current_file:
        files = [f for f in files if f.exists()]

    now = datetime.now().timestamp()

    def _unlink(path: Path):
        try:
            path.unlink(missing_ok=True)  # type: ignore[arg-type]
        except Exception:
            pass

    # Age-based pruning
    if max_age_days is not None and max_age_days >= 0:
        cutoff = now - (max_age_days * 86400)
        for f in list(files):
            if f == current_file:
                continue
            try:
                if f.stat().st_mtime < cutoff:
                    _unlink(f)
                    files.remove(f)
            except FileNotFoundError:
                files.remove(f)

    # Per-source limit (group by base name before ".segments")
    if max_files_per_source is not None and max_files_per_source > 0:
        grouped: Dict[str, List[Path]] = {}
        for f in files:
            key = f.name.split(".segments")[0]
            grouped.setdefault(key, []).append(f)
        for paths in grouped.values():
            # paths already sorted newest→oldest
            for old in paths[max_files_per_source:]:
                if old == current_file:
                    continue
                _unlink(old)
                files.remove(old)

    # Total size pruning
    if max_total_mb is not None and max_total_mb > 0:
        def _file_size(path: Path) -> int:
            try:
                return path.stat().st_size
            except FileNotFoundError:
                return 0

        # Re-sort oldest→newest for eviction
        files_sorted_oldest = sorted(files, key=lambda p: p.stat().st_mtime if p.exists() else 0)
        total_bytes = sum(_file_size(f) for f in files)
        limit_bytes = max_total_mb * 1024 * 1024
        idx = 0
        while total_bytes > limit_bytes and idx < len(files_sorted_oldest):
            victim = files_sorted_oldest[idx]
            idx += 1
            if victim == current_file:
                continue
            total_bytes -= _file_size(victim)
            _unlink(victim)

#######################################################################################################################
# Function Definitions
#

# Convert video .m4a into .wav using ffmpeg
#   ffmpeg -i "example.mp4" -ar 16000 -ac 1 -c:a pcm_s16le "output.wav"
#       https://www.gyan.dev/ffmpeg/builds/
#

# FIXME
# 1. Implement chunking for large audio files
# def speech_to_text(audio_file_path, selected_source_lang='en', whisper_model='medium.en', vad_filter=False, chunk_size=30):
#     # ... existing code ...
#     segments = []
#     for segment_chunk in whisper_model_instance.transcribe(audio_file_path, beam_size=10, best_of=10, vad_filter=vad_filter, chunk_size=chunk_size):
#         # Process each chunk
#         # ... existing code ...
#
# 2. Use generators
#     def generate_segments(segments_raw):
#         for segment_chunk in segments_raw:
#             yield {
#                 "Time_Start": segment_chunk.start,
#                 "Time_End": segment_chunk.end,
#                 "Text": segment_chunk.text
#             }
#     # Usage
#     segments = list(generate_segments(segments_raw))
#
# 3. Use subprocess instead of os.system for ffmpeg
# 4. Adjust CPU threads properly
# 5. Use quantized models - compute_type="int8"


def perform_transcription(
    video_path: str,
    offset: int,
    transcription_model: str,
    vad_use: bool,
    diarize: bool = False,
    overwrite: bool = False,
    transcription_language: str = 'en',
    temp_dir: Optional[str] = None,
    end_seconds: Optional[int] = None,
    ):
    """
    Converts a video or audio file to WAV format, performs transcription,
    and optionally attempts diarization (currently a placeholder).

    The function handles file existence checks and error management throughout
    the process. If diarization is requested, it's currently a non-functional
    placeholder and will likely return with the audio path but no segments.

    Args:
        video_path: The file path to the video or audio file to be processed.
        offset: The time offset (in seconds) from the beginning of the
            media file from which to start processing. This is passed to
            `convert_to_wav`.
        transcription_model: The name or path of the transcription model
            to be used (e.g., 'base.en', 'large-v3').
        vad_use: A boolean indicating whether to use Voice Activity Detection
            (VAD) during transcription.
        diarize: A boolean indicating whether to perform speaker diarization.
            (Note: Diarization functionality is currently a FIXME placeholder
            and not fully implemented).
        overwrite: A boolean indicating whether to overwrite existing
            transcription files. If False and a relevant transcription file
            exists, it will be loaded.
        transcription_language: The language code (e.g., 'en', 'es') for
            transcription. Defaults to 'en'.
        temp_dir: An optional path to a temporary directory. (Note: This
            parameter is not currently used in the function body).
        end_seconds: Optional absolute end time (in seconds) for transcription.
            When provided, audio is clipped to the interval [offset, end_seconds).

    Returns:
        A tuple containing:
        - `Optional[str]`: The file path to the converted WAV audio file.
          This can be `None` if the initial conversion fails.
        - `Optional[list]`: A list of transcription segments. Each segment
          is typically a dictionary with 'start_seconds', 'end_seconds',
          and 'Text'. This can be `None` if transcription (or diarization,
          if attempted) fails or if an existing invalid file is encountered
          with `overwrite=False`.

        Specific return scenarios:
        - `(audio_file_path, segments_list)`: On successful conversion and transcription.
        - `(None, None)`: On critical failure (e.g., media conversion fails).
        - `(audio_file_path, None)`: If conversion succeeds but transcription fails,
          or if diarization is attempted and fails (as it's a placeholder).
    """
    local_media_path_to_convert = None
    temp_dir_manager = None
    downloaded_file_path = None  # Track the specific downloaded file
    audio_file_path = None  # Track generated WAV path even if conversion fails early
    try:
        logging.info(f"Initiating transcription process for: {video_path}")
        # 1. Convert to WAV - Catch ConversionError specifically
        try:
            audio_file_path = convert_to_wav(
                video_path,
                offset=offset,
                end_time=end_seconds,
                overwrite=overwrite
            )
            if not audio_file_path or not os.path.exists(audio_file_path):
                 # This case might occur if convert_to_wav returns None/empty path without raising error
                 logging.error(f"Conversion to WAV failed or produced no file for {video_path}")
                 return None, None # Critical failure
            logging.debug(f"Converted audio file path: {audio_file_path}")
        except ConversionError as e:
            logging.error(f"Audio conversion failed for {video_path}: {e}")
            return None, None # Critical failure, stop processing

        # 2. Define paths
        base_path = os.path.splitext(audio_file_path)[0]
        # Sanitize model name for filename
        transcription_model_sanitized = "".join(c if c.isalnum() or c in ['-', '_'] else '_' for c in transcription_model)
        segments_json_path = f"{base_path}-transcription_model-{transcription_model_sanitized}.segments.json"
        diarized_json_path = f"{base_path}-transcription_model-{transcription_model_sanitized}.diarized.json"

        # --- Perform Diarization and Combination (if requested) ---
        if diarize:
            logging.info(f"Processing with diarization for {audio_file_path}")

            # Check if diarization is available
            if not DIARIZATION_AVAILABLE:
                logging.warning("Diarization requested but not available. Install with: pip install tldw-server[diarization]")
                logging.info("Falling back to regular transcription without diarization")
                diarize = False  # Fall back to regular transcription

            else:
                # Check if diarized file already exists
                if os.path.exists(diarized_json_path) and not overwrite:
                    logging.info(f"Diarized file already exists (overwrite=False): {diarized_json_path}")
                    try:
                        with open(diarized_json_path, 'r', encoding='utf-8') as file:
                            loaded_data = json.load(file)
                        if isinstance(loaded_data, dict) and "segments" in loaded_data:
                            segments = loaded_data["segments"]
                        elif isinstance(loaded_data, list):
                            segments = loaded_data
                        else:
                            raise ValueError("Diarized JSON structure is not valid")

                        # Basic validation
                        if isinstance(segments, list) and all(isinstance(s, dict) and 'Text' in s for s in segments):
                            logging.debug(f"Loaded valid diarized segments from existing file.")
                            return audio_file_path, segments
                        else:
                            logging.warning(f"Existing diarized file {diarized_json_path} has invalid format")
                    except (json.JSONDecodeError, ValueError, TypeError, KeyError) as e:
                        logging.warning(f"Failed to read/parse existing diarized file: {e}")
                        # Continue to regenerate

                # First, get the transcription segments via the unified STT helper
                logging.info(f"Generating transcription for diarization")
                artifact = run_stt_batch_via_registry(
                    audio_file_path,
                    transcription_model,
                    vad_filter=vad_use,
                    selected_source_lang=transcription_language,
                )
                transcription_segments = artifact.get("segments") or []

                if transcription_segments is None:
                    logging.error(f"Transcription generation failed for {audio_file_path}")
                    return audio_file_path, None

                # Now perform diarization
                try:
                    logging.info(f"Performing speaker diarization...")
                    diarization_service = DiarizationService()

                    if not diarization_service.is_available:
                        logging.warning("Diarization service is not available (missing dependencies)")
                        logging.info("Returning transcription without speaker labels")
                        return audio_file_path, transcription_segments

                    # Perform diarization with transcription segments
                    diarized_segments = diarization_service.diarize(
                        audio_path=audio_file_path,
                        transcription_segments=transcription_segments
                    )

                    if diarized_segments and 'segments' in diarized_segments:
                        final_segments = diarized_segments['segments']

                        # Save diarized results
                        with open(diarized_json_path, 'w', encoding='utf-8') as f:
                            json.dump({'segments': final_segments}, f, ensure_ascii=False, indent=2)

                        logging.info(f"Diarization successful. Saved to {diarized_json_path}")
                        return audio_file_path, final_segments
                    else:
                        logging.warning("Diarization returned no segments")
                        return audio_file_path, transcription_segments

                except DiarizationError as e:
                    logging.error(f"Diarization failed for {audio_file_path}: {e}")
                    logging.warning("Proceeding with transcription only due to diarization error.")
                    # Add a warning to the first segment if possible
                    if transcription_segments:
                        transcription_segments[0]['Text'] = f"[Note: Speaker diarization failed] " + transcription_segments[0].get('Text', '')
                    return audio_file_path, transcription_segments

                except Exception as e:
                    logging.error(f"Unexpected error during diarization: {e}", exc_info=True)
                    return audio_file_path, transcription_segments

        # If we get here and diarize was set to False (either originally or as fallback), continue with regular path

        # 4. Handle Non-Diarized Path
        if not diarize:
            logging.info(f"Processing without diarization for {audio_file_path}")
            # Check if non-diarized JSON exists
            if os.path.exists(segments_json_path) and not overwrite:
                logging.info(f"Segments file already exists (overwrite=False): {segments_json_path}")
                try:
                    with open(segments_json_path, 'r', encoding='utf-8') as file:
                        loaded_data = json.load(file)
                    # Handle potential structures: {'segments': [...]} or just [...]
                    if isinstance(loaded_data, dict) and "segments" in loaded_data:
                        segments = loaded_data["segments"]
                    elif isinstance(loaded_data, list):
                        segments = loaded_data
                    else:
                        raise ValueError("JSON structure is not a list or {'segments': list}")

                    # Basic validation
                    if isinstance(segments, list) and all(isinstance(s, dict) and 'Text' in s for s in segments):
                        logging.debug(f"Loaded valid segments from existing file.")
                        return audio_file_path, segments
                    else:
                        logging.warning(f"Existing segments file {segments_json_path} has invalid format, regenerating.")
                except (json.JSONDecodeError, ValueError, TypeError, KeyError) as e:
                    logging.warning(f"Failed to read/parse existing segments file {segments_json_path}: {e}. Regenerating.")

            # Generate new transcription (or overwrite existing)
            logging.info(f"Generating/Overwriting transcription for {audio_file_path}")
            artifact = run_stt_batch_via_registry(
                audio_file_path,
                transcription_model,
                vad_filter=vad_use,
                selected_source_lang=transcription_language,
            )
            segments = artifact.get("segments") or []
            if not segments:
                logging.error(f"Transcription generation failed for {audio_file_path} (no segments)")
                return audio_file_path, None  # Return path, None segments

            # Saving is handled within speech_to_text called by re_generate_transcription
            # but we already checked for overwrite flag above. If overwrite=True,
            # speech_to_text should ideally handle the overwrite when saving.
            # If speech_to_text doesn't handle overwrite, you might need to explicitly delete
            # the old file here before calling re_generate_transcription if overwrite is True.

            logging.info(f"Successfully generated/loaded transcription for {audio_file_path}")
            return audio_file_path, segments

    except Exception as e:
        # Catch-all for unexpected errors during the process
        logging.error(f"Unexpected error in perform_transcription for {video_path}: {e}", exc_info=True)
        # If conversion succeeded, return path, else None. Always return None for segments on error.
        return (audio_file_path, None) if audio_file_path else (None, None)


def re_generate_transcription(audio_file_path, whisper_model, vad_filter, selected_source_lang='en'):
    """
    Calls `speech_to_text` to perform transcription on an audio file and handles potential errors.

    This function serves as a wrapper around `speech_to_text`, primarily for
    regenerating transcriptions. It ensures that all necessary parameters are
    passed to `speech_to_text` and catches exceptions that might occur during
    the transcription process.

    Args:
        audio_file_path: The path to the audio file to be transcribed.
        whisper_model: The name or path of the Whisper model to use for transcription.
        vad_filter: A boolean indicating whether to use Voice Activity Detection (VAD).
        selected_source_lang: The language code for the source audio (e.g., 'en', 'es').
            Defaults to 'en'.

    Returns:
        A tuple containing:
        - `str`: The `audio_file_path` that was processed.
        - `Optional[list]`: A list of transcription segments if successful,
          or `None` if transcription fails or yields no segments. Each segment is
          a dictionary, typically with 'start_seconds', 'end_seconds', and 'Text'.
    """
    logging.info(f"Regenerating transcription for {audio_file_path} using model {whisper_model}")
    try:
        # IMPORTANT: Pass all necessary parameters to speech_to_text.
        # The canonical return type of speech_to_text is a list of segment
        # dicts (or (segments, language) when return_language=True). We treat
        # any dict-with-'segments' shape here as a defensive normalization
        # only, not as a public contract.
        segments = speech_to_text(
            audio_file_path,
            whisper_model=whisper_model,
            selected_source_lang=selected_source_lang,  # Ensure language is passed
            vad_filter=vad_filter,
            diarize=False  # Explicitly false for non-diarized regeneration
        )
        # speech_to_text returns the segments list directly on success (or raises).
        # Normalize a dict-with-'segments' defensively in case a future change or
        # external wrapper passes that shape through.
        if isinstance(segments, dict) and 'segments' in segments:
            actual_segments = segments['segments']
        else:
            actual_segments = segments  # Assuming it returns the list directly now or handles errors by raising

        if not actual_segments:
            logging.warning(f"Re-generation yielded no segments for {audio_file_path}")
            return audio_file_path, None  # Return path but None segments on empty result

        logging.info(f"Successfully re-generated transcription for {audio_file_path}")
        return audio_file_path, actual_segments
    except RuntimeError as e:
        logging.error(f"RuntimeError during re_generate_transcription for {audio_file_path}: {e}")
        return audio_file_path, None  # Return path but None segments on error
    except Exception as e:
        logging.error(f"Unexpected error during re_generate_transcription for {audio_file_path}: {e}", exc_info=True)
        return audio_file_path, None  # Return path but None segments on error


#####################################
# Memory-Saving Indefinite Recording
#####################################

class PartialTranscriptionThread(threading.Thread):
    """
    A thread that performs partial (live) transcriptions on audio chunks.

    This thread consumes audio data from a queue, maintains a rolling buffer
    of recent audio, and periodically attempts to transcribe this buffer to
    provide near real-time transcription updates.

    Attributes:
        audio_queue (queue.Queue): Queue to get audio chunks from.
        stop_event (threading.Event): Event to signal the thread to stop.
        partial_text_state (dict): A dictionary (shared state, needs locking)
            to store the latest partial transcription text under the key "text".
        lock (threading.Lock): Lock to protect access to `partial_text_state`.
        live_model (str): The transcription model to use for partial transcriptions.
        sample_rate (int): The sample rate of the input audio (Hz).
        channels (int): The number of audio channels.
        partial_update_interval (float): How often (in seconds) to attempt a
            partial transcription.
        partial_chunk_seconds (float): The duration (in seconds) of the audio
            rolling buffer to keep in memory for partial transcription.
        exception_encountered (Optional[Exception]): Stores any exception that
            occurs during the `run` method.
    """
    def __init__(
        self,
        audio_queue: queue.Queue,
        stop_event: threading.Event,
        partial_text_state: dict,
        lock: threading.Lock,
        live_model: str,          # model for partial
        sample_rate=44100,
        channels=2,
        partial_update_interval=2.0,   # how often we attempt a partial transcription
        partial_chunk_seconds=5,
    ):
        """
        Initializes the PartialTranscriptionThread.

        Args:
            audio_queue: Queue for incoming audio data chunks (bytes).
            stop_event: Event to signal termination of the thread.
            partial_text_state: Dictionary to store the output partial transcription.
                Must be thread-safe if accessed from outside without the lock.
            lock: A threading.Lock instance to synchronize access to `partial_text_state`.
            live_model: Name or path of the transcription model for live updates.
            sample_rate: Expected sample rate of the audio in Hz.
            channels: Number of audio channels.
            partial_update_interval: Interval in seconds between transcription attempts.
            partial_chunk_seconds: Maximum duration of audio (in seconds) to hold in
                the rolling buffer for partial transcription.
        """
        super().__init__(daemon=True)
        self.audio_queue = audio_queue
        self.stop_event = stop_event
        self.partial_text_state = partial_text_state
        self.lock = lock
        self.live_model = live_model

        self.sample_rate = sample_rate
        self.channels = channels
        self.partial_update_interval = partial_update_interval
        self.partial_chunk_seconds = partial_chunk_seconds

        # Rolling buffer for partial
        self.audio_buffer = b""
        # We only keep last X seconds in memory for partial
        self.max_partial_bytes = int(self.partial_chunk_seconds * self.sample_rate * self.channels * 2)
        # Also enforce a hard limit based on configuration
        max_buffer_bytes = AUDIO_TRANSCRIPTION_BUFFER_SIZE_MB * 1024 * 1024
        self.max_partial_bytes = min(self.max_partial_bytes, max_buffer_bytes)

        self.last_ts = time.time()

        # Keep track of any exceptions
        self.exception_encountered = None

    def run(self):
        """
        Main loop for the partial transcription thread.

        Continuously reads audio data from the queue, appends it to a
        rolling buffer, and periodically transcribes the buffer content.
        Updates `self.partial_text_state` with the latest transcription.
        If an error occurs during transcription, it's stored in
        `self.exception_encountered`.
        """
        while not self.stop_event.is_set():
            now = time.time()
            if now - self.last_ts < self.partial_update_interval:
                time.sleep(0.1)
                continue

            # Gather new chunks from the queue
            new_data = []
            while not self.audio_queue.empty():
                chunk = self.audio_queue.get_nowait()
                new_data.append(chunk)

            if new_data:
                combined_new_data = b"".join(new_data)
                # Append to rolling buffer
                self.audio_buffer += combined_new_data

                # Enforce maximum partial buffer size
                if len(self.audio_buffer) > self.max_partial_bytes:
                    self.audio_buffer = self.audio_buffer[-self.max_partial_bytes:]

            # If rolling buffer is large enough, do partial transcription
            if len(self.audio_buffer) > (self.sample_rate * self.channels * 2):  # ~1s
                try:
                    # Convert from 16-bit PCM to float32
                    audio_np = np.frombuffer(self.audio_buffer, dtype=np.int16).astype(np.float32) / 32768.0

                    # If channels=2, you may want to downmix to mono:
                    # If your STT supports stereo, skip this step.
                    if self.channels == 2:
                        audio_np = audio_np.reshape((-1, 2))
                        audio_np = np.mean(audio_np, axis=1)  # simple stereo -> mono

                    # Transcribe using configured provider or default via the
                    # shared STT provider registry. This keeps default selection
                    # consistent with the rest of the STT module and the PRD.
                    try:
                        from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.stt_provider_adapter import (  # type: ignore
                            get_stt_provider_registry,
                        )

                        provider = get_stt_provider_registry().get_default_provider_name()
                    except Exception:
                        # Defensive fallback in case the registry cannot be
                        # imported in a constrained environment.
                        config = loaded_config_data or load_and_log_configs()
                        provider = "faster-whisper"
                        if config and 'STT-Settings' in config:
                            provider = config['STT-Settings'].get('default_transcriber', 'faster-whisper')

                    if provider == 'parakeet':
                        from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Nemo import (
                            transcribe_with_parakeet
                        )
                        variant = config['STT-Settings'].get('nemo_model_variant', 'standard') if config else 'standard'
                        partial_text = transcribe_with_parakeet(audio_np, self.sample_rate, variant)
                    elif provider == 'canary':
                        from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Nemo import (
                            transcribe_with_canary
                        )
                        partial_text = transcribe_with_canary(audio_np, self.sample_rate, "en")
                    else:
                        partial_text = transcribe_audio(
                            audio_np,
                            sample_rate=self.sample_rate,
                            whisper_model=self.live_model,
                            speaker_lang="en",
                            transcription_provider=provider
                        )

                    with self.lock:
                        self.partial_text_state["text"] = partial_text
                except Exception as e:
                    self.exception_encountered = e
                    logging.error(f"Partial transcription error: {e}")

            self.last_ts = time.time()


##########################################################
# Transcription Sink Function
def transcribe_audio(audio_data: np.ndarray, transcription_provider, sample_rate: int = 16000, speaker_lang=None, whisper_model="distil-large-v3") -> str:
    """
    Canonical waveform-based entry point for speech-to-text across providers.

    This helper is the central sink used by higher-level modules (REST endpoints,
    speech chat, live tools) when they already have in-memory audio. It selects
    the transcription provider based on the `transcription_provider` argument or
    a default from configuration and normalizes results to a plain text string.

    It currently supports 'qwen2audio', 'parakeet', 'canary', 'external:*', and
    'faster-whisper'. For 'faster-whisper', it routes to `speech_to_text` and
    merges the returned segments into a single user-facing transcript.

    Args:
        audio_data: A NumPy array containing the raw audio waveform (float32).
        transcription_provider: The name of the transcription provider to use
            (e.g., 'qwen2audio', 'parakeet', 'faster-whisper'). If None,
            the default is loaded from configuration.
        sample_rate: The sample rate of the `audio_data` in Hz.
        speaker_lang: The language code of the audio (e.g., 'en', 'es').
            Used by faster-whisper. If None, language detection may be attempted
            by the underlying model.
        whisper_model: The specific model name or path to use if 'faster-whisper'
            is the provider (e.g., 'distil-large-v3', 'base.en').

    Returns:
        The transcribed text as a string. This function never returns segments;
        all providers are normalized to a single text transcript.

        For providers that may fail (e.g. Qwen2Audio, external backends), this
        function returns a provider-specific error sentinel such as
        "[Transcription error] ...". Callers that surface `transcribe_audio`
        output to users must treat such sentinels as structured errors via
        `is_transcription_error_message` rather than as real user speech.

    Notes:
        - `transcribe_audio` is the preferred entry point whenever you already
          have a NumPy waveform (speech chat, WebSocket sinks, background audio
          tools).
        - `speech_to_text` is the canonical file/segment-based entry point used
          by media ingestion and offline workers; it returns structured segments
          (or `(segments, language)` when requested) rather than plain text.
    """
    # Load STT settings safely; fall back to sane defaults if missing/malformed.
    try:
        stt_cfg = get_stt_config()
    except Exception:
        stt_cfg = {}

    if not transcription_provider:
        # Load default transcription provider via config file, but guard against
        # missing sections/keys so we always have a sane default.
        transcription_provider = stt_cfg.get("default_transcriber", "faster-whisper")

    if transcription_provider.lower() == 'qwen2audio':
        logging.info("Transcribing using Qwen2Audio")
        try:
            return transcribe_with_qwen2audio(audio_data, sample_rate)
        except Exception as e:
            logging.error(f"Qwen2Audio transcription failed: {e}", exc_info=True)
            return f"[Transcription error] Qwen2Audio transcription failed: {e}"

    elif transcription_provider.lower() == "parakeet":
        logging.info("Transcribing using Parakeet")
        try:
            from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Nemo import (
                transcribe_with_parakeet
            )
            # Get model variant from config
            variant = stt_cfg.get('nemo_model_variant', 'standard')

            return transcribe_with_parakeet(audio_data, sample_rate, variant)
        except ImportError as e:
            logging.error(f"Failed to import Nemo transcription module: {e}")
            return "Nemo transcription module not available. Please check installation."
        except Exception as e:
            logging.error(f"Parakeet transcription failed: {e}")
            return f"Parakeet transcription error: {str(e)}"

    elif transcription_provider.lower() == "canary":
        logging.info("Transcribing using Canary")
        try:
            from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Nemo import (
                transcribe_with_canary
            )
            return transcribe_with_canary(audio_data, sample_rate, speaker_lang)
        except ImportError as e:
            logging.error(f"Failed to import Nemo transcription module: {e}")
            return "Nemo transcription module not available. Please check installation."
        except Exception as e:
            logging.error(f"Canary transcription failed: {e}")
            return f"Canary transcription error: {str(e)}"

    elif transcription_provider.lower() == "external" or transcription_provider.lower().startswith("external:"):
        logging.info("Transcribing using external provider")
        try:
            from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_External_Provider import (
                transcribe_with_external_provider
            )
            # Check if a specific provider is specified (e.g., "external:myapi")
            provider_name = "default"
            if ":" in transcription_provider:
                provider_name = transcription_provider.split(":", 1)[1]

            return transcribe_with_external_provider(
                audio_data,
                sample_rate=sample_rate,
                provider_name=provider_name,
                language=speaker_lang
            )
        except ImportError as e:
            logging.error(f"Failed to import external provider module: {e}")
            return "External provider module not available. Please check installation."
        except Exception as e:
            logging.error(f"External provider transcription failed: {e}")
            return f"External provider transcription error: {str(e)}"

    else:
        logging.info(f"Transcribing using faster-whisper with model: {whisper_model}")

        segments = speech_to_text(
            audio_data,
            whisper_model=whisper_model,
            selected_source_lang=speaker_lang,
            input_sample_rate=sample_rate,
        )
        if isinstance(segments, dict) and 'error' in segments:
            # handle error
            return f"Error in transcription: {segments['error']}"

        # Merge all segment texts
        final_text = " ".join(seg["Text"] for seg in segments['segments']) if isinstance(segments, dict) else " ".join(
            seg["Text"] for seg in segments)
        return final_text

#
# End of Sink Function
##########################################################


def is_transcription_error_message(msg: str) -> bool:
    """
    Heuristic to detect transcription error sentinel strings.

    This centralizes detection used by API endpoints and speech chat so that
    provider-specific error messages stay in sync with callers.
    """
    if not isinstance(msg, str):
        return False

    lower_msg = msg.lower().strip()
    if not lower_msg:
        return False

    return (
        lower_msg.startswith("[error")
        or lower_msg.startswith("[transcription error")
        or lower_msg.startswith("error in transcription")
        or lower_msg.startswith("canary transcription error")
        or lower_msg.startswith("parakeet transcription error")
        or lower_msg.startswith("external provider transcription error")
        or lower_msg.startswith("external provider module not available")
        or lower_msg.startswith("external provider transcription failed")
        or lower_msg.startswith("nemo transcription module not available")
        or lower_msg.startswith("failed to import nemo")
        or lower_msg.startswith("failed to import external provider")
    )


def strip_whisper_metadata_header(segments):
    """
    Remove the Whisper metadata header from the first segment's Text, if present.

    The speech_to_text function prepends a header like:
        "This text was transcribed using whisper model: ...\\n"
        "Detected language: ...\\n\\n"
    into the first segment. For API/user-facing flows we often want the bare
    transcript, so this helper trims that header in-place.

    Args:
        segments: Either a list of segment dicts or a dict with a "segments" list.

    Returns:
        The same segments object that was passed in (mutated if a header was removed).
    """
    try:
        if isinstance(segments, dict):
            seg_list = segments.get("segments") or []
        else:
            seg_list = segments

        if not seg_list:
            return segments

        first = seg_list[0]
        if not isinstance(first, dict):
            return segments

        text = first.get("Text")
        if not isinstance(text, str):
            return segments

        header_prefix = "This text was transcribed using whisper model:"
        if not text.startswith(header_prefix):
            return segments

        # Preferred: split on the blank line separating header from content
        parts = text.split("\n\n", 1)
        if len(parts) == 2:
            first["Text"] = parts[1]
            return segments

        # Fallback: drop the first two lines (model + language)
        lines = text.splitlines()
        if len(lines) >= 3:
            first["Text"] = "\n".join(lines[2:])

        return segments
    except Exception:
        # Never fail the caller because of header stripping
        return segments


def to_normalized_stt_artifact(
    text: str,
    segments,
    *,
    language: Optional[str] = None,
    provider: Optional[str] = None,
    model: Optional[str] = None,
    duration_seconds: Optional[float] = None,
    diarization_enabled: bool = False,
    diarization_speakers: Optional[int] = None,
) -> Dict[str, Any]:
    """
    Build a normalized STT artifact from text/segments.

    This helper is used by ingestion/Jobs to ensure a consistent internal
    representation of STT results, even when different code paths
    (REST, media ingestion, workers) assemble transcripts differently.
    """
    # Normalize segments into a list
    seg_list: List[Dict[str, Any]] = []
    try:
        if isinstance(segments, dict):
            maybe = segments.get("segments")
            if isinstance(maybe, list):
                seg_list = maybe
        elif isinstance(segments, list):
            seg_list = segments
    except Exception:
        seg_list = []

    duration_ms: Optional[int] = None
    if duration_seconds is not None:
        try:
            duration_ms = round(max(float(duration_seconds), 0.0) * 1000)
        except Exception:
            duration_ms = None

    return {
        "text": text or "",
        "language": language,
        "segments": seg_list,
        "diarization": {"enabled": bool(diarization_enabled), "speakers": diarization_speakers},
        "usage": {"duration_ms": duration_ms, "tokens": None},
        "metadata": {
            "provider": provider or "",
            "model": model or "",
        },
    }


@lru_cache(maxsize=1)
def _valid_whisper_model_sizes_for_jobs() -> set:
    """
    Cached lookup of known faster-whisper model sizes for jobs helper.

    Mirrors the audio endpoint's _valid_whisper_model_sizes but is defined
    here to avoid circular imports between core audio libs and API modules.
    """
    try:
        return set(getattr(WhisperModel, "valid_model_sizes", []))
    except Exception:
        # If WhisperModel is unavailable, fall back to empty set so that
        # model mapping simply treats all inputs as aliases.
        return set()


def _map_openai_audio_model_to_whisper_for_jobs(model: Optional[str]) -> str:
    """
    Map OpenAI-style audio model ids to a faster-whisper model name.

    This mirrors the behavior of the REST audio endpoint's
    `_map_openai_audio_model_to_whisper` helper so that Jobs use the same
    model mapping semantics (e.g., 'whisper-1' -> 'large-v3'). Unknown
    values fall back to 'large-v3'.
    """
    default_model = "large-v3"
    if not model:
        return default_model

    raw = str(model).strip()
    if not raw:
        return default_model

    m = raw.lower()
    valid_sizes = _valid_whisper_model_sizes_for_jobs()

    # Pass through known internal sizes and HF ids
    if raw in valid_sizes or m in valid_sizes or "/" in raw:
        return raw

    # OpenAI-compatible aliases
    if m == "whisper-1":
        return default_model

    # Fallback to default
    return default_model


def run_stt_batch_via_registry(
    audio_file_path: str,
    transcription_model: str,
    *,
    vad_filter: bool = False,
    selected_source_lang: str = "en",
    duration_seconds: Optional[float] = None,
) -> Dict[str, Any]:
    """
    Run batch STT via the shared provider registry and return a normalized artifact.

    This helper centralizes provider/model resolution for ingestion-style flows.
    For Whisper-family models it delegates to `re_generate_transcription` to
    preserve existing caching behaviour; for other providers it uses the
    adapter-based `transcribe_batch` implementation.
    """
    from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.stt_provider_adapter import (  # type: ignore
        get_stt_provider_registry,
    )

    registry = get_stt_provider_registry()
    provider, provider_model, _ = registry.resolve_provider_for_model(transcription_model or "")
    adapter = registry.get_adapter(provider)

    # Whisper-family models: reuse the canonical regeneration helper so that
    # transcript cache files and error semantics remain unchanged.
    if provider == "faster-whisper":
        _, segments = re_generate_transcription(
            audio_file_path,
            transcription_model,
            vad_filter,
            selected_source_lang=selected_source_lang,
        )
        if segments is None:
            raise RuntimeError("STT transcription failed; no segments produced")

        text = " ".join(
            str(seg.get("Text", "")).strip()
            for seg in segments
            if isinstance(seg, dict)
        )
        return to_normalized_stt_artifact(
            text=text,
            segments=segments,
            language=selected_source_lang,
            provider=provider,
            model=provider_model or transcription_model,
            duration_seconds=duration_seconds,
        )

    # Non-Whisper providers: use adapter transcribe_batch directly.
    artifact = adapter.transcribe_batch(
        audio_file_path,
        model=provider_model or transcription_model,
        language=selected_source_lang,
        task="transcribe",
        word_timestamps=False,
        prompt=None,
    )

    # Ensure duration_ms is set when we know duration.
    if duration_seconds is not None:
        try:
            duration_ms = round(max(float(duration_seconds), 0.0) * 1000)
            usage = artifact.setdefault("usage", {})
            if usage.get("duration_ms") is None:
                usage["duration_ms"] = duration_ms
        except Exception as e:
            logging.debug(f"Failed to set duration_ms in artifact: {e}")

    return artifact


def run_stt_job_via_registry(
    wav_path: str,
    model: Optional[str],
    language: Optional[str],
) -> Dict[str, Any]:
    """
    Run STT for Jobs via the shared provider registry and return a normalized artifact.

    This helper is intended for worker-style flows (audio_jobs_worker,
    audio_transcribe_gpu_worker). It mirrors the REST endpoint's behavior for
    Whisper-family models by reusing the same OpenAI-style model mapping
    semantics (e.g., 'whisper-1' -> 'large-v3') while delegating to provider
    adapters for non-Whisper providers.
    """
    from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.stt_provider_adapter import (  # type: ignore
        get_stt_provider_registry,
    )

    registry = get_stt_provider_registry()
    # When no model is provided, fall back to the OpenAI-style default
    # alias so that Jobs align with the REST audio endpoint's behavior.
    requested_model = (model or "whisper-1").strip()
    provider, provider_model, _ = registry.resolve_provider_for_model(requested_model)
    adapter = registry.get_adapter(provider)

    # Whisper-family models: reuse the OpenAI-compatible alias mapping so
    # that 'whisper-1' and similar identifiers resolve consistently across
    # REST, ingestion, and Jobs.
    if provider == "faster-whisper":
        whisper_model_name = _map_openai_audio_model_to_whisper_for_jobs(requested_model)
        selected_lang = language or None
        artifact = adapter.transcribe_batch(
            wav_path,
            model=whisper_model_name,
            language=selected_lang,
            task="transcribe",
            word_timestamps=False,
            prompt=None,
        )
        return artifact

    # Non-Whisper providers: use adapter transcribe_batch directly with the
    # resolved provider-specific model identifier.
    artifact = adapter.transcribe_batch(
        wav_path,
        model=provider_model or requested_model,
        language=language or None,
        task="transcribe",
        word_timestamps=False,
        prompt=None,
    )
    return artifact


##########################################################
#
# Qwen2-Audio-related Functions

# Load Qwen2Audio (lazy load or load once at startup)
qwen_processor = None
qwen_model = None

def load_qwen2audio():
    """
    Loads the Qwen2Audio model and processor.

    This function implements lazy loading: the model and processor are loaded
    only on the first call and then cached in global variables for subsequent
    calls. It uses "Qwen/Qwen2-Audio-7B-Instruct" from Hugging Face Hub.

    Returns:
        A tuple `(processor, model)`:
        - `processor`: The `AutoProcessor` for Qwen2Audio.
        - `model`: The `Qwen2AudioForConditionalGeneration` model.

    Raises:
        RuntimeError: When Qwen2Audio is explicitly disabled or not configured
            in `[STT-Settings].qwen2audio_enabled`. In this case the error
            message is a sentinel string starting with
            "[Transcription error] Qwen2Audio is disabled or not configured"
            so higher-level helpers (e.g. `transcribe_audio`) can convert it
            into a consistent error sentinel for REST and speech-chat flows.
        ImportError: If `transformers` library is not installed.
        Exception: Propagates errors from `from_pretrained` if model downloading
            or loading fails (e.g., network issues, insufficient memory).
    """
    global qwen_processor, qwen_model
    if qwen_processor is None or qwen_model is None:
        # Gate heavy Qwen2Audio loading behind config so typical installs
        # do not attempt to download/initialize this large model unless
        # explicitly enabled.
        cfg = load_and_log_configs() or {}
        stt_cfg = cfg.get("STT-Settings") or {}
        enabled_raw = stt_cfg.get("qwen2audio_enabled")
        if enabled_raw is None or not _to_bool(enabled_raw):
            logging.warning(
                "Qwen2Audio requested but STT-Settings.qwen2audio_enabled is not set or false; "
                "treating Qwen2Audio as disabled."
            )
            raise RuntimeError("[Transcription error] Qwen2Audio is disabled or not configured")

        model_id = stt_cfg.get("qwen2audio_model_id", "Qwen/Qwen2-Audio-7B-Instruct")
        logging.info(f"Loading Qwen2Audio model: {model_id}")

        qwen_processor = AutoProcessor.from_pretrained(model_id)
        qwen_model = Qwen2AudioForConditionalGeneration.from_pretrained(
            model_id,
            torch_dtype=torch.float16,
            device_map="auto"
        )
    return qwen_processor, qwen_model

def transcribe_with_qwen2audio(audio: np.ndarray, sample_rate: int = 16000) -> str:
    """
    Transcribes an audio waveform using the Qwen2Audio model.

    This function takes a raw audio NumPy array, processes it with the
    Qwen2Audio processor, and generates a transcription using the model's
    ASR capabilities. It uses a specific prompt structure required by
    Qwen2Audio for transcription tasks.

    Args:
        audio: A NumPy array representing the raw audio waveform (float32).
        sample_rate: The sample rate of the input `audio` in Hz.

    Returns:
        The transcribed text as a string. Returns an empty string or raises
        an exception if transcription fails; error sentinel strings with the
        "[Transcription error]" prefix are produced by higher-level helpers
        such as `transcribe_audio`, not by this function directly.

    Raises:
        RuntimeError: May propagate the sentinel-style `RuntimeError` raised by
            `load_qwen2audio` when Qwen2Audio is disabled or misconfigured.
        Exception: Any other error encountered while preparing model inputs
            or during `model.generate`.
    """
    processor, model = load_qwen2audio()

    # We build a prompt that includes <|audio_bos|><|AUDIO|><|audio_eos|> token(s)
    # The simplest approach is "User: <|AUDIO|>"
    # But Qwen2Audio also uses special tokens <|audio_bos|> and <|audio_eos|>.
    prompt_text = "System: You are a transcription model.\nUser: <|audio_bos|><|AUDIO|><|audio_eos|>\nAssistant:"

    inputs = processor(
        text=prompt_text,
        audios=audio,
        return_tensors="pt",
        sampling_rate=sample_rate
    )
    device = model.device
    for k, v in inputs.items():
        if isinstance(v, torch.Tensor):
            inputs[k] = v.to(device)

    with torch.no_grad():
        generated_ids = model.generate(**inputs, max_new_tokens=128)
    # The raw output has prompt + transcription + possibly more text
    transcription = processor.decode(generated_ids[0], skip_special_tokens=True)

    # Post-process transcription
    # Qwen2Audio might produce additional text.
    # Typically you look for the part after "Assistant:"
    # or remove your system prompt if it appears in the output.
    # A quick approach:
    if "Assistant:" in transcription:
        # e.g. "System: ... User: <|AUDIO|>\nAssistant: Hello here's your text"
        transcription = transcription.split("Assistant:")[-1].strip()

    return transcription

#
# End of Qwen2-Audio-related Functions
##########################################################


##########################################################
#
# Faster Whisper related functions
whisper_model_instance = None
config = load_and_log_configs() or {}
processing_choice = config.get("processing_choice", "cpu")
total_thread_count = multiprocessing.cpu_count()

# Model download status tracking
model_download_status = {}

def check_model_exists(model_name: str) -> bool:
    """
    Check if a Whisper model is already downloaded.

    Args:
        model_name: Name of the model to check

    Returns:
        True if model exists locally, False otherwise
    """
    default_download_root = str(WHISPER_MODEL_BASE_DIR)

    # Check if it's a path that exists
    if os.path.exists(model_name):
        return True

    # Check in default download directory
    model_path = os.path.join(default_download_root, model_name)
    if os.path.isdir(model_path):
        return True

    # Check if it's a Hub ID that might be cached
    if '/' in model_name:
        # Convert Hub ID to potential cache path
        cache_name = model_name.replace('/', '_')
        cache_path = os.path.join(default_download_root, cache_name)
        if os.path.isdir(cache_path):
            return True

    # Check faster-whisper's default cache location
    home_cache = os.path.expanduser("~/.cache/huggingface/hub")
    if os.path.exists(home_cache):
        # Look for model in HuggingFace cache
        pattern = os.path.join(home_cache, f"*{model_name.replace('/', '--')}*")
        if glob.glob(pattern):
            return True

    return False

def set_model_download_status(model_name: str, status: str, message: str):
    """
    Set the download status for a model.

    Args:
        model_name: Name of the model
        status: Status of the download ('checking', 'downloading', 'completed', 'error')
        message: Human-readable status message
    """
    global model_download_status
    model_download_status[model_name] = {
        'status': status,
        'message': message,
        'timestamp': time.time()
    }
    logging.info(f"Model download status for {model_name}: {status} - {message}")

def get_model_download_status(model_name: str) -> Optional[Dict[str, Any]]:
    """
    Get the current download status for a model.

    Args:
        model_name: Name of the model

    Returns:
        Dictionary with status information or None if no status
    """
    return model_download_status.get(model_name)

class WhisperModel(OriginalWhisperModel):
    """
    Custom wrapper for `faster_whisper.WhisperModel` to manage model loading.

    This class extends the original `faster_whisper.WhisperModel` to provide
    customized model path resolution (Hugging Face Hub ID, local path, or
    standard model name) and sets a default download root for models.

    Attributes:
        default_download_root (str): The default directory path where models
            will be downloaded or looked for if not found elsewhere. This is
            set relative to the `tldw_Server_API` directory structure.
        valid_model_sizes (List[str]): A list of recognized standard model size
            names and some known community model identifiers.
        model_identifier (str): The resolved identifier (path or name) used to
            load the model.
    """
    default_download_root = str(WHISPER_MODEL_BASE_DIR)

    valid_model_sizes = [
        "tiny.en", "tiny", "base.en", "base", "small.en", "small", "medium.en", "medium",
        "large-v1", "large-v2", "large-v3", "large", "distil-large-v2", "distil-medium.en",
        "distil-small.en", "distil-large-v3", "deepdml/faster-distil-whisper-large-v3.5", "deepdml/faster-whisper-large-v3-turbo-ct2",
        "nyrahealth/faster_CrisperWhisper"
    ]

    def __init__(
        self,
        model_size_or_path: str,
        device: str = processing_choice,
        device_index: Union[int, List[int]] = 0,
        compute_type: str = "default",
        cpu_threads: int = 0,#total_thread_count, FIXME - I think this should be 0
        num_workers: int = 1,
        download_root: Optional[str] = None,
        local_files_only: bool = False,
        files: Optional[Dict[str, Any]] = None,
        **model_kwargs: Any
    ):
        """
        Initializes the custom WhisperModel.

        Determines if `model_size_or_path` is a Hugging Face Hub ID, an
        existing local path, or a standard model name. It then calls the
        parent `faster_whisper.WhisperModel` initializer with the resolved
        identifier and specified `download_root`.

        Args:
            model_size_or_path: Identifier for the model. Can be:
                - A standard model size name (e.g., "large-v3", "tiny.en").
                - A path to a local model directory.
                - A Hugging Face Hub model ID (e.g., "openai/whisper-large-v3").
            device: Device to load the model on ("cpu", "cuda", "auto").
            device_index: Index of the device(s) to use.
            compute_type: Type of computation to use (e.g., "float16", "int8").
            cpu_threads: Number of CPU threads to use for inference.
                Set to 0 for faster-whisper to auto-detect.
            num_workers: Number of workers for parallel transcription.
            download_root: Path to the directory for downloading/caching models.
                If None, uses `WhisperModel.default_download_root`.
            local_files_only: If True, only look for local files and do not
                attempt to download.
            files: Optional dictionary of specific files to use for the model,
                   as per faster-whisper's `OriginalWhisperModel`.
            **model_kwargs: Additional keyword arguments passed to the
                `faster_whisper.WhisperModel` constructor.

        Raises:
            ValueError: If the model identifier is invalid, cannot be resolved,
                or if `faster_whisper.WhisperModel` initialization fails.
            RuntimeError: For other unexpected errors during model loading.
        """
        if download_root is None:
            download_root = self.default_download_root # Use your default path

        os.makedirs(download_root, exist_ok=True) # Ensure your target directory exists
        resolved_identifier = model_size_or_path # Start with the original input

        # Check 1: Does it contain '/' and is NOT an existing local directory/file?
        # This is likely a Hugging Face Hub ID.
        is_potential_hub_id = '/' in model_size_or_path and not os.path.exists(model_size_or_path)

        # Check 2: Is it an existing local directory or file?
        is_existing_local_path = os.path.exists(model_size_or_path)

        if is_potential_hub_id:
            # Assume it's a Hub ID - pass it directly to faster-whisper.
            # faster-whisper will handle downloading it (potentially respecting download_root if configured).
            logging.info(f"Treating '{model_size_or_path}' as a Hugging Face Hub ID.")
            resolved_identifier = model_size_or_path # Pass the Hub ID string as is
        elif is_existing_local_path:
            # It's a local path that exists - pass the absolute path.
             logging.info(f"Treating '{model_size_or_path}' as an existing local path.")
             resolved_identifier = os.path.abspath(model_size_or_path)
        else:
            # Assume it's a standard model size name (e.g., "large-v3").
            # Let faster-whisper handle finding/downloading this standard model.
            # It will likely use the provided `download_root` or its internal default cache.
            logging.info(f"Treating '{model_size_or_path}' as a standard model size name.")
            resolved_identifier = model_size_or_path # Pass the name

            custom_path_check = os.path.join(download_root, model_size_or_path)
            if os.path.isdir(custom_path_check):
                logging.info(f"Found standard model '{model_size_or_path}' in custom download root: {custom_path_check}")
                resolved_identifier = custom_path_check # Use the local path
            else:
                logging.info(f"Standard model '{model_size_or_path}' not in custom root. Passing name to faster-whisper.")
                # resolved_identifier remains the model size name

        # --- Pass the determined identifier and other args to the parent ---
        logging.info(
             f"Initializing faster-whisper with: model='{resolved_identifier}', "
             f"device='{device}', compute_type='{compute_type}', "
             f"download_root='{download_root}', local_files_only={local_files_only}"
        )

        try:
            super().__init__(
                model_size_or_path=resolved_identifier, # Use the corrected identifier
                device=device,
                device_index=device_index,
                compute_type=compute_type,
                cpu_threads=cpu_threads,
                num_workers=num_workers,
                download_root=download_root, # Pass your custom root
                local_files_only=local_files_only,
                **model_kwargs
            )
            self.model_identifier = resolved_identifier # Store for reference if needed
            logging.info(f"Successfully initialized WhisperModel: {resolved_identifier}")

        except ValueError as e:
            # Error during faster-whisper init (e.g., invalid model, download failed)
            logging.error(f"Failed to initialize faster_whisper.WhisperModel with '{resolved_identifier}': {e}", exc_info=True)
            # Provide a more specific error message based on the likely cause
            if "Invalid model size" in str(e) or "could not be found" in str(e):
                 raise ValueError(f"The model identifier '{resolved_identifier}' is invalid or could not be loaded/downloaded. Check the name/path and ensure it's accessible.") from e
            else:
                 raise ValueError(f"Error initializing model '{resolved_identifier}': {e}") from e
        except Exception as e:
             # Catch other unexpected errors
             logging.error(f"An unexpected error occurred during faster_whisper.WhisperModel initialization with '{resolved_identifier}': {e}", exc_info=True)
             raise RuntimeError(f"Unexpected error loading model: {resolved_identifier} - {e}") from e

# Model unloading functions
def unload_whisper_model():
    """
    Unloads the global faster-whisper model instance and triggers garbage collection.

    This function is intended to free up resources, particularly GPU memory,
    used by the loaded Whisper model. It deletes the reference to the global
    `whisper_model_instance` (if it exists and was set by `get_whisper_model` or
    directly) and also clears the `whisper_model_cache`.

    Note:
        If `whisper_model_instance` was not the sole reference to the model object
        (e.g., if it's also in `whisper_model_cache` and that cache is used),
        deleting it alone might not free memory until the cache is also cleared
        or the Python garbage collector reclaims the object. This function now
        explicitly clears the cache.
    """
    global whisper_model_instance
    whisper_model_instance = None  # kept for backward compat
    with whisper_model_cache_lock:
        whisper_model_cache.clear()
    gc.collect()


def unload_all_transcription_models():
    """
    Unload all transcription models (Whisper, Qwen, Nemo) to free memory.
    """
    # Unload Whisper models
    unload_whisper_model()

    # Unload Qwen2Audio models
    global qwen_processor, qwen_model
    if qwen_processor is not None:
        del qwen_processor
        qwen_processor = None
    if qwen_model is not None:
        del qwen_model
        qwen_model = None

    # Unload Nemo models
    try:
        from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Nemo import (
            unload_nemo_models
        )
        unload_nemo_models()
    except ImportError:
        pass  # Nemo module not available

    # Force garbage collection
    gc.collect()

    # Clear GPU cache if available
    if torch.cuda.is_available():
        torch.cuda.empty_cache()

    logging.info("Unloaded all transcription models from memory")

whisper_model_cache = {}
whisper_model_cache_lock = threading.Lock()

def get_whisper_model(model_name, device, check_download_status=False):
    """
    Retrieves or initializes a `WhisperModel` instance, using a cache.

    This function checks a cache for an existing model instance matching the
    `model_name`, `device`, and a determined `compute_type`. If not found,
    it initializes a new `WhisperModel` instance, stores it in the cache,
    and returns it.

    The `compute_type` is resolved as follows:
      - When `[STT-Settings].whisper_compute_type` is set to a non-empty value
        other than "auto", that value is passed through to the underlying
        faster-whisper constructor (for example: "float16", "int8",
        "int8_float16").
      - Otherwise, the compute type defaults to "float16" when `device`
        contains "cuda" and "int8" for CPU devices.

    Args:
        model_name: The name or path of the Whisper model (e.g., "base.en",
            "/path/to/model", "openai/whisper-large-v3").
        device: The device to load the model on ("cpu", "cuda").
        check_download_status: If True, check if model needs downloading and return status.

    Returns:
        A `WhisperModel` instance, or a tuple (None, status_dict) if check_download_status
        is True and model needs downloading.

    Raises:
        ValueError: If `WhisperModel` initialization fails (e.g., invalid model name).
        RuntimeError: For other unexpected errors during model loading.
    """
    # Optional override from STT-Settings; when unset or "auto", fall back to
    # the prior device-based heuristic.
    if WHISPER_COMPUTE_TYPE_OVERRIDE and WHISPER_COMPUTE_TYPE_OVERRIDE != "auto":
        compute_type = WHISPER_COMPUTE_TYPE_OVERRIDE
    else:
        compute_type = "float16" if "cuda" in device else "int8"
    cache_key = (model_name, device, compute_type)

    with whisper_model_cache_lock:
        # If checking download status and model not in cache
        if check_download_status and cache_key not in whisper_model_cache:
            if not check_model_exists(model_name):
                return None, {
                    'status': 'model_downloading',
                    'message': (
                        f'Model {model_name} is not available locally and will be '
                        'downloaded on first use. This may take several minutes '
                        'depending on your internet connection.'
                    ),
                    'model': model_name
                }

        if cache_key not in whisper_model_cache:
            logging.info(f"Cache miss. Initializing WhisperModel for key: {cache_key}")
            try:
                # This now calls the *corrected* WhisperModel.__init__
                instance = WhisperModel(
                    model_size_or_path=model_name,
                    device=device,
                    compute_type=compute_type
                )
                whisper_model_cache[cache_key] = instance
            except (ValueError, RuntimeError) as e:
                # Check if the error is related to CUDA not being available
                if "cuda" in device.lower() and ("CUDA" in str(e) or "cuda" in str(e).lower()):
                    logging.warning(f"CUDA initialization failed for {model_name}: {e}. Falling back to CPU.")
                    # Try again with CPU
                    cpu_compute_type = "int8"
                    cpu_cache_key = (model_name, "cpu", cpu_compute_type)

                    if cpu_cache_key not in whisper_model_cache:
                        try:
                            instance = WhisperModel(
                                model_size_or_path=model_name,
                                device="cpu",
                                compute_type=cpu_compute_type
                            )
                            whisper_model_cache[cpu_cache_key] = instance
                            # Also cache it under the original key to avoid retrying CUDA
                            whisper_model_cache[cache_key] = instance
                            logging.info("Successfully initialized WhisperModel on CPU as fallback")
                            return instance
                        except (ValueError, RuntimeError) as cpu_e:
                            logging.error(f"Failed to initialize WhisperModel on CPU as well: {cpu_e}")
                            raise RuntimeError(f"Failed to initialize model on both CUDA and CPU: {cpu_e}") from cpu_e
                    else:
                        # Use existing CPU instance
                        instance = whisper_model_cache[cpu_cache_key]
                        whisper_model_cache[cache_key] = instance  # Cache under original key too
                        return instance
                else:
                    logging.error(f"Fatal error creating whisper model instance for key {cache_key}: {e}")
                    raise  # Re-raise the exception
        else:
            logging.debug(f"Cache hit. Reusing existing WhisperModel instance for key: {cache_key}")

        return whisper_model_cache[cache_key]


# Transcribe .wav into .segments.json
#DEBUG
#@profile
# FIXME - I feel like the `vad_filter` should be enabled by default....
@timeit
def format_time(total_seconds: float) -> str:
    """
    Convert a float number of seconds into HH:MM:SS format.
    E.g., 123.45 -> '00:02:03'
    """
    hours = int(total_seconds // 3600)
    minutes = int((total_seconds % 3600) // 60)
    seconds = int(total_seconds % 60)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}"

def parse_transcription_model(model_name: str) -> tuple:
    """
    Parse model name to extract provider, model, and variant.

    Examples:
    - "parakeet-mlx" -> ("parakeet", "parakeet", "mlx")
    - "parakeet-onnx" -> ("parakeet", "parakeet", "onnx")
    - "parakeet-cuda" -> ("parakeet", "parakeet", "cuda")
    - "parakeet-standard" -> ("parakeet", "parakeet", "standard")
    - "nemo-canary-1b" -> ("canary", "canary", "standard")
    - "nemo-parakeet-tdt-1.1b" -> ("parakeet", "parakeet", "standard")
    - "whisper-large-v3" -> ("whisper", "large-v3", None)
    - "distil-whisper-large-v3" -> ("whisper", "distil-whisper-large-v3", None)

    Returns:
        Tuple of (provider, model, variant)
    """
    model_lower = model_name.lower()

    # Check for Parakeet models with variants
    if "parakeet" in model_lower:
        if model_lower.endswith("-mlx"):
            return ("parakeet", "parakeet", "mlx")
        elif model_lower.endswith("-onnx"):
            return ("parakeet", "parakeet", "onnx")
        elif model_lower.endswith("-cuda"):
            return ("parakeet", "parakeet", "cuda")
        elif model_lower.endswith("-standard") or "nemo-parakeet" in model_lower:
            return ("parakeet", "parakeet", "standard")
        else:
            # Default to standard if no variant specified
            return ("parakeet", "parakeet", "standard")

    # Check for Canary models
    elif "canary" in model_lower:
        return ("canary", "canary", "standard")

    # Check for Qwen2Audio models
    elif "qwen2audio" in model_lower or "qwen2-audio" in model_lower:
        return ("qwen2audio", model_name, None)

    # Default to whisper for all other models
    else:
        # This includes whisper-*, distil-whisper-*, deepdml/*, etc.
        return ("whisper", model_name, None)

def create_segments_from_text(text: str, audio_duration: float = None, segmentation: str = "sentence") -> list:
    """
    Convert plain text to whisper-compatible segment format with sentence or word-level segmentation.

    Args:
        text: The transcribed text
        audio_duration: Optional duration of the audio in seconds
        segmentation: Segmentation level - "sentence" (default), "word", or "full"

    Returns:
        List of segment dictionaries compatible with whisper format
    """
    import re

    if not text:
        return []

    text = text.strip()
    segments = []

    if segmentation == "sentence":
        # Split by sentence-ending punctuation, keeping the punctuation
        # This regex splits on .!? followed by space or end of string, keeping the delimiter
        sentence_pattern = r'(?<=[.!?])\s+|(?<=[.!?])$'
        sentences = re.split(sentence_pattern, text)
        # Remove empty strings
        sentences = [s.strip() for s in sentences if s.strip()]

        if not sentences:
            sentences = [text]  # Fallback if no sentence boundaries found

        # Estimate time distribution across sentences
        if audio_duration:
            # Calculate time per character for even distribution
            total_chars = sum(len(s) for s in sentences)
            if total_chars > 0:
                time_per_char = audio_duration / total_chars
            else:
                time_per_char = 0

            current_time = 0.0
            for sentence in sentences:
                sentence_duration = len(sentence) * time_per_char
                end_time = current_time + sentence_duration

                segments.append({
                    "start_seconds": current_time,
                    "end_seconds": end_time,
                    "text": sentence,
                    "Text": sentence,  # Some code expects "Text" key
                    "start": format_time(current_time),
                    "end": format_time(end_time),
                    "Time_Start": current_time,
                    "Time_End": end_time
                })

                current_time = end_time
        else:
            # No duration info, just create segments without timing
            for i, sentence in enumerate(sentences):
                segments.append({
                    "start_seconds": 0.0,
                    "end_seconds": 0.0,
                    "text": sentence,
                    "Text": sentence,
                    "start": "00:00:00",
                    "end": "00:00:00",
                    "Time_Start": 0.0,
                    "Time_End": 0.0
                })

    elif segmentation == "word":
        # Split by whitespace to get words
        words = text.split()

        if audio_duration and words:
            # Distribute time evenly across words
            time_per_word = audio_duration / len(words)

            for i, word in enumerate(words):
                start_time = i * time_per_word
                end_time = (i + 1) * time_per_word

                segments.append({
                    "start_seconds": start_time,
                    "end_seconds": end_time,
                    "text": word,
                    "Text": word,
                    "start": format_time(start_time),
                    "end": format_time(end_time),
                    "Time_Start": start_time,
                    "Time_End": end_time
                })
        else:
            # No duration info
            for word in words:
                segments.append({
                    "start_seconds": 0.0,
                    "end_seconds": 0.0,
                    "text": word,
                    "Text": word,
                    "start": "00:00:00",
                    "end": "00:00:00",
                    "Time_Start": 0.0,
                    "Time_End": 0.0
                })

    else:  # "full" or any other value
        # Single segment with full text
        segments = [{
            "start_seconds": 0.0,
            "end_seconds": audio_duration if audio_duration else 0.0,
            "text": text,
            "Text": text,
            "start": "00:00:00",
            "end": format_time(audio_duration) if audio_duration else "00:00:00",
            "Time_Start": 0.0,
            "Time_End": audio_duration if audio_duration else 0.0
        }]

    return segments

def speech_to_text_parakeet(
    audio_file_path: str,
    variant: str = "standard",
    selected_source_lang: str = 'en',
    vad_filter: bool = False
) -> list:
    """
    Transcribe audio using Parakeet with specified variant.

    Args:
        audio_file_path: Path to the audio file
        variant: Parakeet variant ('standard', 'onnx', 'mlx', 'cuda')
        selected_source_lang: Language code (not used by Parakeet currently)
        vad_filter: VAD filter flag (not used by Parakeet currently)

    Returns:
        List of segments in whisper-compatible format
    """
    try:
        logging.info(f"Transcribing with Parakeet variant: {variant}")

        # Get audio duration for segment creation
        try:
            import librosa
            audio_duration = librosa.get_duration(path=audio_file_path)
        except Exception:
            audio_duration = None
            logging.warning("Could not determine audio duration")

        # Route to appropriate Parakeet implementation
        if variant == "mlx":
            # Use MLX implementation (macOS only)
            try:
                from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Parakeet_MLX import (
                    transcribe_with_parakeet_mlx, check_mlx_available
                )

                if not check_mlx_available():
                    logging.warning("MLX not available on this platform, falling back to standard Parakeet")
                    variant = "standard"
                else:
                    # MLX implementation expects file path directly
                    text = transcribe_with_parakeet_mlx(audio_file_path)
                    # Default to sentence-level segmentation
                    return create_segments_from_text(text, audio_duration, segmentation="sentence")

            except ImportError as e:
                logging.warning(f"Could not import Parakeet MLX: {e}. Falling back to standard.")
                variant = "standard"

        # For other variants or fallback, use Nemo implementation
        from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Nemo import (
            transcribe_with_parakeet
        )

        # Load audio data for Nemo implementation
        import numpy as np
        import librosa
        audio_data, sample_rate = librosa.load(audio_file_path, sr=16000, mono=True)

        # Transcribe with Parakeet
        text = transcribe_with_parakeet(audio_data, sample_rate, variant)

        # Convert to segment format with sentence-level segmentation
        return create_segments_from_text(text, audio_duration, segmentation="sentence")

    except Exception as e:
        logging.error(f"Parakeet transcription failed: {e}")
        raise RuntimeError(f"Parakeet transcription error: {str(e)}") from e

def speech_to_text_canary(
    audio_file_path: str,
    selected_source_lang: str = 'en',
    vad_filter: bool = False
) -> list:
    """
    Transcribe audio using Canary model.

    Args:
        audio_file_path: Path to the audio file
        selected_source_lang: Language code
        vad_filter: VAD filter flag (not used by Canary currently)

    Returns:
        List of segments in whisper-compatible format
    """
    try:
        logging.info("Transcribing with Canary model")

        from tldw_Server_API.app.core.Ingestion_Media_Processing.Audio.Audio_Transcription_Nemo import (
            transcribe_with_canary
        )

        # Load audio data
        import numpy as np
        import librosa
        audio_data, sample_rate = librosa.load(audio_file_path, sr=16000, mono=True)

        # Get audio duration
        audio_duration = librosa.get_duration(y=audio_data, sr=sample_rate)

        # Transcribe with Canary
        text = transcribe_with_canary(audio_data, sample_rate, selected_source_lang)

        # Convert to segment format with sentence-level segmentation
        return create_segments_from_text(text, audio_duration, segmentation="sentence")

    except Exception as e:
        logging.error(f"Canary transcription failed: {e}")
        raise RuntimeError(f"Canary transcription error: {str(e)}") from e

def speech_to_text_qwen2audio(
    audio_file_path: str,
    selected_source_lang: str = 'en',
    vad_filter: bool = False
) -> list:
    """
    Transcribe audio using Qwen2Audio model.

    Args:
        audio_file_path: Path to the audio file
        selected_source_lang: Language code (not used by Qwen2Audio currently)
        vad_filter: VAD filter flag (not used by Qwen2Audio currently)

    Returns:
        List of segments in whisper-compatible format
    """
    try:
        logging.info("Transcribing with Qwen2Audio model via speech_to_text_qwen2audio")

        # Load audio data
        import numpy as np
        import librosa
        audio_data, sample_rate = librosa.load(audio_file_path, sr=16000, mono=True)

        # Get audio duration
        audio_duration = librosa.get_duration(y=audio_data, sr=sample_rate)

        # Transcribe with Qwen2Audio
        text = transcribe_with_qwen2audio(audio_data, sample_rate)

        # Convert to segment format with sentence-level segmentation
        return create_segments_from_text(text, audio_duration, segmentation="sentence")

    except Exception as e:
        logging.error(f"Qwen2Audio transcription failed: {e}")
        raise RuntimeError(f"Qwen2Audio transcription error: {str(e)}") from e

def speech_to_text(
    audio_input: Union[str, Path, np.ndarray],
    whisper_model: str = "distil-large-v3",
    selected_source_lang: str = "en",  # Changed order of parameters
    vad_filter: bool = False,
    diarize: bool = False,
    *,
    word_timestamps: bool = False,
    return_language: bool = False,
    persist_segments: Optional[bool] = None,
    cache_max_age_days: Optional[int] = None,
    cache_max_total_mb: Optional[float] = None,
    cache_max_files_per_source: Optional[int] = None,
    initial_prompt: Optional[str] = None,
    task: str = "transcribe",
    input_sample_rate: Optional[int] = None,
) -> Union[List[Dict[str, Any]], Tuple[List[Dict[str, Any]], Optional[str]]]:
    """
    Canonical file/segment-based speech-to-text helper.

    This function is the primary entry point for offline/media workflows that
    work with files (or occasionally in-memory waveforms). It loads the
    requested model/provider, performs transcription, and returns structured
    segments suitable for storage and downstream processing.

    Args:
        audio_input: Path to the WAV audio file to be transcribed, or a NumPy array
            of audio samples.
        whisper_model: Name or path of the faster-whisper model to use
            (e.g., 'distil-large-v3', 'base.en').
        selected_source_lang: Language code of the source audio (e.g., 'en', 'es').
            If `None`, the model will attempt to auto-detect the language.
            Defaults to 'en'.
        vad_filter: If True, applies Voice Activity Detection filter during
            transcription to potentially improve accuracy by filtering out non-speech
            segments.
        diarize: Placeholder for diarization flag. This parameter is not currently
            used within this function's transcription logic.
        task: Whisper decoding task to perform. Valid values are "transcribe"
            (default, transcribe in source language) and "translate" (translate
            non-English speech to English, when supported by the underlying
            model/provider). For non-Whisper providers this value is ignored and
            transcription is always performed.
        input_sample_rate: Sample rate of the provided NumPy array input. Ignored when a
            file path is provided.

    Returns:
        By default, a list of segment dictionaries. Each dictionary contains:
        - "start_seconds" (float): Start time of the segment in seconds.
        - "end_seconds" (float): End time of the segment in seconds.
        - "Text" (str): The transcribed text of the segment.
        - Optional "words" (list): When word_timestamps=True, a list of
          {"start": float, "end": float, "word": str} entries per segment.
        The first segment may include metadata about the transcription model
        and detected language prepended to its "Text" field.

        When `return_language` is True, returns a tuple of
        `(segments, language_or_none)` for all providers. For Whisper,
        `language_or_none` is the detected language; for other providers it
        will typically be the `selected_source_lang` value or None.

        This function never returns plain text; callers that need a single
        transcript string should explicitly merge segment "Text" fields or use
        `transcribe_audio` instead.

    Raises:
        ValueError: If `audio_input` is not provided or is invalid.
        FileNotFoundError: If the audio path does not exist.
        RuntimeError: If transcription fails for other reasons (e.g., model loading
            error, issue during transcription process, or if no segments are produced).
            The original exception may be chained.
    """
    file_path: Optional[Path] = None
    file_path_label = "<memory>"
    time_start = time.time()

    if audio_input is None or (isinstance(audio_input, (str, Path)) and not str(audio_input)):
        log_counter("speech_to_text_error", labels={"error": "No audio input provided"})
        raise ValueError("speech-to-text: No audio input provided")

    # Parse the model name to determine the provider
    provider, model, variant = parse_transcription_model(whisper_model)

    # Normalize task for Whisper-based providers. For non-Whisper providers,
    # this is ignored and we always perform transcription.
    task_normalized = str(task or "transcribe").strip().lower()
    if task_normalized not in {"transcribe", "translate"}:
        task_normalized = "transcribe"

    # If a file path is provided, resolve and validate it
    audio_path_for_model: Union[str, np.ndarray]
    if isinstance(audio_input, (str, Path)):
        file_path = Path(audio_input).resolve()
        file_path_label = str(file_path)
        if not file_path.exists():
            log_counter("speech_to_text_error", labels={"error": "Audio file not found", "file_path": str(file_path)})
            raise FileNotFoundError(f"speech-to-text: Audio file not found at {file_path}")
        audio_path_for_model = str(file_path)
    else:
        audio_np = np.asarray(audio_input, dtype=np.float32).flatten()
        sr = input_sample_rate or 16000
        audio_path_for_model = _resample_audio_if_needed(audio_np, sr, target_sr=16000)

    log_counter("speech_to_text_attempt", labels={"file_path": file_path_label, "model": whisper_model})

    # Route to the appropriate transcription provider (only supports file paths today)
    if provider == "parakeet":
        if file_path is None:
            raise ValueError("speech-to-text: Parakeet provider requires an audio file path")
        logging.info(f"Routing to Parakeet transcription with variant: {variant}")
        try:
            segments_parakeet = speech_to_text_parakeet(
                audio_file_path=str(file_path),
                variant=variant,
                selected_source_lang=selected_source_lang,
                vad_filter=vad_filter,
            )
            if return_language:
                return segments_parakeet, selected_source_lang
            return segments_parakeet
        except Exception as e:
            logging.error(f"Parakeet transcription failed, falling back to whisper: {e}")
            provider = "whisper"
            model = "distil-whisper-large-v3"  # Default fallback model

    elif provider == "canary":
        if file_path is None:
            raise ValueError("speech-to-text: Canary provider requires an audio file path")
        logging.info("Routing to Canary transcription")
        try:
            segments_canary = speech_to_text_canary(
                audio_file_path=str(file_path),
                selected_source_lang=selected_source_lang,
                vad_filter=vad_filter,
            )
            if return_language:
                return segments_canary, selected_source_lang
            return segments_canary
        except Exception as e:
            logging.error(f"Canary transcription failed, falling back to whisper: {e}")
            provider = "whisper"
            model = "distil-whisper-large-v3"

    elif provider == "qwen2audio":
        if file_path is None:
            raise ValueError("speech-to-text: Qwen2Audio provider requires an audio file path")
        logging.info("Routing to Qwen2Audio transcription")
        try:
            segments_qwen = speech_to_text_qwen2audio(
                audio_file_path=str(file_path),
                selected_source_lang=selected_source_lang,
                vad_filter=vad_filter,
            )
            if return_language:
                return segments_qwen, selected_source_lang
            return segments_qwen
        except Exception as e:
            logging.error(f"Qwen2Audio transcription failed, falling back to whisper: {e}")
            provider = "whisper"
            model = "distil-whisper-large-v3"

    # If we get here, use the original whisper implementation
    # Update whisper_model to use the parsed model name (in case we fell back)
    if provider == "whisper":
        whisper_model = model

    logging.info(
        f"speech-to-text: Starting transcription for: {file_path_label}"
    )
    logging.info(f"speech-to-text: Model={whisper_model}, Lang={selected_source_lang or 'auto'}, VAD={vad_filter}")

    try:
        out_file = prettified_out_file = None
        if file_path is not None:
            sanitized_whisper_model_name = sanitize_filename(whisper_model)
            out_file = file_path.with_name(f"{file_path.stem}-whisper_model-{sanitized_whisper_model_name}.segments.json")
            prettified_out_file = file_path.with_name(f"{file_path.stem}-whisper_model-{sanitized_whisper_model_name}.segments_pretty.json")

        options = dict(beam_size=5, best_of=5, vad_filter=vad_filter)  # Simplified beam options
        if selected_source_lang:
            options["language"] = selected_source_lang
        if word_timestamps:
            options["word_timestamps"] = True

        # For Whisper, propagate the desired decoding task. Only "transcribe"
        # and "translate" are accepted; other values are coerced earlier.
        transcribe_options = dict(task=task_normalized, **options)
        combined_prompt = None
        if initial_prompt:
            combined_prompt = str(initial_prompt).strip() or None

        try:
            from .Audio_Custom_Vocabulary import initial_prompt_if_enabled
            _init_prompt = initial_prompt_if_enabled()
            if _init_prompt:
                _init_prompt = str(_init_prompt).strip()
                if _init_prompt:
                    if combined_prompt:
                        combined_prompt = f"{_init_prompt}\n{combined_prompt}"
                    else:
                        combined_prompt = _init_prompt
        except Exception as _cv_err:
            logging.debug(f"Custom vocab initial_prompt injection skipped: {_cv_err}")

        if combined_prompt:
            transcribe_options["initial_prompt"] = combined_prompt

        # Get model instance (cached)
        whisper_model_instance = get_whisper_model(
            whisper_model,
            processing_choice,
            check_download_status=False,
        )

        # Perform transcription (supports numpy arrays directly)
        segments_raw, info = whisper_model_instance.transcribe(audio_path_for_model, **transcribe_options)

        detected_lang = getattr(info, "language", None)
        lang_prob = getattr(info, "language_probability", None)
        if detected_lang:
            logging.info(f"speech-to-text: Detected language: {detected_lang} (Confidence: {lang_prob:.2f})")

        segments = []
        for segment_chunk in segments_raw:
            chunk = {
                "start_seconds": segment_chunk.start,
                "end_seconds": segment_chunk.end,
                "Text": segment_chunk.text.strip() # Strip whitespace from text
            }
            if word_timestamps and hasattr(segment_chunk, 'words') and segment_chunk.words:
                try:
                    words_list = []
                    for w in segment_chunk.words:
                        words_list.append({
                            "start": float(w.start) if w.start is not None else None,
                            "end": float(w.end) if w.end is not None else None,
                            "word": getattr(w, 'word', getattr(w, 'text', '')).strip(),
                        })
                    if words_list:
                        chunk["words"] = words_list
                except Exception:
                    pass
            logging.debug(f"Segment: {chunk}")
            try:
                from .Audio_Custom_Vocabulary import postprocess_text_if_enabled
                chunk["Text"] = postprocess_text_if_enabled(chunk["Text"]) or chunk["Text"]
            except Exception:
                pass
            segments.append(chunk)
            logging.debug(f"Segment: [{chunk['start_seconds']:.2f}-{chunk['end_seconds']:.2f}] {chunk['Text'][:100]}...")

        if segments:
            segments[0]["Text"] = (
                f"This text was transcribed using whisper model: {whisper_model}\n"
                f"Detected language: {detected_lang}\n\n"
                f"{segments[0]['Text']}"
            )

        if not segments:
            log_counter("speech_to_text_error", labels={"error": "No transcription produced"})
            raise RuntimeError("No transcription produced. The audio may be invalid or empty.")

        transcription_time = time.time() - time_start
        logging.info(f"speech-to-text: Transcription completed in {transcription_time:.2f} seconds. Segments: {len(segments)}")
        log_histogram(
            "speech_to_text_duration",
            transcription_time,
            labels={"file_path": file_path_label, "model": whisper_model}
        )
        log_counter("speech_to_text_success", labels={"file_path": file_path_label, "model": whisper_model, "segments": len(segments)})

        # Persist transcription to disk for cache-aware callers (unless disabled)
        _persist_allowed = (file_path is not None) and (PERSIST_TRANSCRIPTS_DEFAULT if persist_segments is None else persist_segments)
        if _persist_allowed and out_file and prettified_out_file:
            try:
                payload = {"segments": segments}
                with open(out_file, "w", encoding="utf-8") as f:
                    json.dump(payload, f, ensure_ascii=False)
                try:
                    with open(prettified_out_file, "w", encoding="utf-8") as f:
                        json.dump(payload, f, ensure_ascii=False, indent=2)
                except Exception as prettify_err:
                    logging.debug(f"Failed to write prettified transcription file: {prettify_err}")

                prune_transcript_cache(
                    file_path.parent,
                    current_file=out_file,
                    max_age_days=cache_max_age_days if cache_max_age_days is not None else CACHE_MAX_AGE_DAYS,
                    max_total_mb=cache_max_total_mb if cache_max_total_mb is not None else CACHE_MAX_TOTAL_MB,
                    max_files_per_source=cache_max_files_per_source if cache_max_files_per_source is not None else CACHE_MAX_FILES_PER_SOURCE,
                )
            except Exception as persist_err:
                logging.warning(f"Could not persist transcription segments to cache: {persist_err}")

        gc.collect() # Suggest garbage collection
        if return_language:
            return segments, detected_lang
        return segments

    except Exception as e:
        logging.error(f"speech-to-text: Error transcribing audio {file_path_label}: {e}", exc_info=True)
        log_counter(
            "speech_to_text_error",
            labels={"file_path": file_path_label, "model": whisper_model, "error": type(e).__name__}
        )
        if file_path is not None:
            raise RuntimeError(f"speech-to-text: Error during transcription of {file_path.name}") from e
        raise RuntimeError("speech-to-text: Error during transcription of in-memory audio") from e

#
# End of Faster Whisper related functions
##########################################################

##########################################################
#
# Audio Conversion

class ConversionError(Exception):
    """Custom exception for errors during audio/video conversion."""
    pass

_FFMPEG_VERSION_CHECKED: bool = False
_FFMPEG_CMD_FOR_VERSION: Optional[str] = None


def _find_ffmpeg() -> str:
    """
    Finds the ffmpeg executable by checking common locations.

    Order of checks:
    1. Relative path: `../../Bin/ffmpeg.exe` (for Windows, specific to project structure).
    2. Environment variable: `FFMPEG_PATH`.
    3. System PATH: Uses `shutil.which("ffmpeg")`.

    Returns:
        The absolute path to the found ffmpeg executable as a string.

    Raises:
        FileNotFoundError: If ffmpeg is not found in any of the checked locations.
    """
    # 1. Check project Bin path (Windows) relative to repo structure
    if os.name == 'nt':
        try:
            app_dir = Path(__file__).resolve().parents[3]  # .../app
            bin_dir = app_dir / "Bin"
            ffmpeg_exe = bin_dir / "ffmpeg.exe"
            if ffmpeg_exe.exists():
                logging.debug(f"Found ffmpeg at project Bin path: {ffmpeg_exe}")
                return str(ffmpeg_exe)
        except Exception:
            pass

    # 2. Check environment variable (useful for Docker/server setups)
    ffmpeg_env = os.environ.get("FFMPEG_PATH")
    if ffmpeg_env and Path(ffmpeg_env).exists():
        logging.debug(f"Found ffmpeg via FFMPEG_PATH env var: {ffmpeg_env}")
        return ffmpeg_env

    # 3. Project Bin path for non-Windows environments
    try:
        app_dir = Path(__file__).resolve().parents[3]
        candidate = app_dir / "Bin" / "ffmpeg"
        if candidate.exists():
            logging.debug(f"Found ffmpeg at project Bin path: {candidate}")
            return str(candidate)
    except Exception:
        pass

    # 4. Check PATH using shutil.which (cross-platform)
    ffmpeg_path = shutil.which("ffmpeg")
    if ffmpeg_path:
        logging.debug(f"Found ffmpeg in system PATH: {ffmpeg_path}")
        return ffmpeg_path

    # 5. If not found, raise error
    raise FileNotFoundError("ffmpeg executable not found in Bin directory, FFMPEG_PATH, or system PATH.")

# os.system(r'.\Bin\ffmpeg.exe -ss 00:00:00 -i "{video_file_path}" -ar 16000 -ac 1 -c:a pcm_s16le "{out_path}"')

def validate_audio_file(file_path: str) -> tuple:
    """
    Validate audio file using ffprobe.

    Args:
        file_path: Path to the audio file to validate

    Returns:
        Tuple of (is_valid, error_message)

    Notes:
        This helper is a best-effort preflight check. When ffprobe is missing
        or misconfigured, unexpected exceptions are logged and the function
        returns ``(True, "Validation warning: ...")`` so that downstream
        conversion still runs and can surface more precise errors. The
        ``convert_to_wav`` helper applies stricter handling for clearly
        corrupted files (for example, zero channels or missing audio streams)
        and raises ``ConversionError`` in those cases.
    """
    try:
        # Check file exists and has minimum size
        path = Path(file_path)
        if not path.exists():
            return False, "File does not exist"

        stat = path.stat()
        file_size = stat.st_size
        if file_size < 1024:  # Less than 1KB
            return False, f"File too small ({file_size} bytes), likely corrupted"

        cache_key = str(path.resolve())
        cached = _AUDIO_VALIDATION_CACHE.get(cache_key)
        if cached and cached[0] == stat.st_mtime:
            return cached[1], cached[2]

        # Find ffprobe command
        ffmpeg_cmd = _find_ffmpeg()
        ffprobe_cmd = shutil.which("ffprobe")
        if not ffprobe_cmd:
            ffmpeg_path = Path(ffmpeg_cmd)
            suffix = ffmpeg_path.suffix  # '.exe' on Windows
            candidate = ffmpeg_path.with_name(f"ffprobe{suffix}")
            ffprobe_cmd = str(candidate) if candidate.exists() else "ffprobe"

        # Use ffprobe to check for audio streams
        result = subprocess.run(
            [ffprobe_cmd, "-v", "error", "-select_streams", "a:0",
             "-show_entries", "stream=codec_name,channels,sample_rate",
             "-of", "json", str(file_path)],
            capture_output=True,
            text=True,
            timeout=10  # 10 second timeout
        )

        if result.returncode != 0:
            return False, f"FFprobe failed: {result.stderr[:200] if result.stderr else 'Unknown error'}"

        # Parse JSON output
        try:
            import json
            probe_data = json.loads(result.stdout)
            if not probe_data.get('streams'):
                return False, "No audio streams detected in file"

            # Check first audio stream
            stream = probe_data['streams'][0]
            if stream.get('channels', 0) == 0:
                return False, "Audio stream has 0 channels"

        except (json.JSONDecodeError, KeyError) as e:
            # If we can't parse the output, try simpler check
            if not result.stdout.strip():
                return False, "No audio stream detected"

        # Cache successful validation result keyed by file path + mtime so
        # repeated conversions of the same file avoid an extra ffprobe call.
        _AUDIO_VALIDATION_CACHE[cache_key] = (stat.st_mtime, True, "")
        return True, ""

    except subprocess.TimeoutExpired:
        return False, "File validation timed out (possible corrupt file)"
    except Exception as e:
        logging.warning(f"Audio validation error: {e}")
        # Don't fail completely if validation has issues; allow FFmpeg-based
        # conversion/transcription to attempt processing and surface any errors.
        return True, f"Validation warning: {str(e)}"

#DEBUG
#@profile
@timeit
def convert_to_wav(
    video_file_path: str,
    offset: int = 0,
    end_time: Optional[int] = None,
    overwrite: bool = False,
) -> str:
    """
    Converts a video or audio file to a standardized WAV format using ffmpeg.

    The output WAV file is 16kHz, mono, 16-bit PCM signed little-endian,
    which is suitable for many speech recognition systems, including Whisper.
    The output file is saved in the same directory as the input file with
    a ".wav" extension.

    Args:
        video_file_path: The path to the input video or audio file.
        offset: The start offset in seconds from the beginning of the input
            file. ffmpeg's `-ss` parameter will be set to this value.
        end_time: Optional absolute end time in seconds. When provided, only the
            portion between `offset` and `end_time` is converted.
        overwrite: If True, overwrite the output WAV file if it already
            exists. If False and the file exists, the conversion is skipped,
            and the path to the existing file is returned.

    Returns:
        The absolute path to the generated (or existing) WAV file as a string.

    Raises:
        FileNotFoundError: If the input `video_file_path` does not exist.
        RuntimeError: If the ffmpeg executable cannot be found or fails basic version check.
        ConversionError: If the ffmpeg conversion process fails (e.g., invalid input file,
            ffmpeg command returns non-zero exit code). This can also wrap other
            unexpected errors during ffmpeg execution.
    """
    log_counter("convert_to_wav_attempt", labels={"file_path": video_file_path})
    start_time = time.time()

    input_path = Path(video_file_path)
    if not input_path.exists():
        raise FileNotFoundError(f"Input file not found: {video_file_path}")

    # Output path in the same directory as input
    out_path = input_path.with_suffix(".wav")

    # Avoid in-place writes when input is already a .wav
    # If input and output resolve to the same file and overwrite is False, skip conversion.
    # If overwrite is True, choose a different output filename to prevent FFmpeg in-place editing.
    try:
        same_path = out_path.resolve() == input_path.resolve()
    except Exception:
        same_path = str(out_path) == str(input_path)
    if same_path:
        if not overwrite:
            logging.info(
                f"Skipping conversion for WAV input (overwrite=False): {out_path}"
            )
            log_counter("convert_to_wav_skipped", labels={"file_path": video_file_path})
            return str(out_path)
        else:
            # Select a new output filename in the same directory
            alt_out_path = input_path.with_name(f"{input_path.stem}_16k_mono.wav")
            out_path = alt_out_path

    if out_path.exists() and not overwrite:
        logging.info(f"Skipping conversion as WAV file already exists and overwrite=False: {out_path}")
        log_counter("convert_to_wav_skipped", labels={"file_path": video_file_path})
        return str(out_path)

    if end_time is not None:
        if end_time <= offset:
            raise ConversionError(
                f"Invalid end time ({end_time}) provided; must be greater than offset ({offset})."
            )
        duration_seconds = end_time - offset
    else:
        duration_seconds = None

    # Determine ffmpeg executable path (honors FFMPEG_PATH and project Bin/)
    try:
        ffmpeg_cmd = _find_ffmpeg()
    except FileNotFoundError as e:
        error_msg = str(e)
        logging.error(error_msg)
        raise RuntimeError(error_msg) from e

    # Verify ffmpeg command works, but avoid re-running the relatively
    # expensive `ffmpeg -version` probe on every conversion. We cache the
    # last-verified command and only re-check when the resolved executable
    # path changes (for example, if FFMPEG_PATH/env is updated).
    global _FFMPEG_VERSION_CHECKED, _FFMPEG_CMD_FOR_VERSION
    try:
        if not _FFMPEG_VERSION_CHECKED or _FFMPEG_CMD_FOR_VERSION != ffmpeg_cmd:
            subprocess.run(
                [ffmpeg_cmd, "-version"],
                check=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                stdin=subprocess.DEVNULL,
            )
            logging.debug(f"Confirmed ffmpeg command '{ffmpeg_cmd}' is available.")
            _FFMPEG_VERSION_CHECKED = True
            _FFMPEG_CMD_FOR_VERSION = ffmpeg_cmd
    except (FileNotFoundError, subprocess.CalledProcessError) as e:
        error_msg = (
            f"ffmpeg command '{ffmpeg_cmd}' not found or failed execution. "
            "Please ensure ffmpeg is installed and in the system PATH or in the "
            f"expected ./Bin directory. Error: {e}"
        )
        logging.error(error_msg)
        raise RuntimeError(error_msg) from e


    # Validate the audio file first (optional preflight). This can be disabled
    # via STT_SKIP_AUDIO_PREVALIDATION / [STT-Settings].skip_audio_prevalidation
    # for throughput-critical deployments that prefer to rely on ffmpeg alone.
    if not SKIP_AUDIO_PREVALIDATION:
        is_valid, validation_msg = validate_audio_file(str(input_path))
        if not is_valid:
            logging.warning(f"Audio file validation warning for '{input_path.name}': {validation_msg}")
            # For critical validation failures, we should fail early
            if "0 channels" in validation_msg or "No audio stream" in validation_msg or "too small" in validation_msg:
                logging.error(f"Critical audio file issue detected, cannot proceed with conversion: {validation_msg}")

                # Special handling for potential format mismatch (e.g., m4a file with .mp3 extension)
                if str(input_path).lower().endswith('.mp3') and "0 channels" in validation_msg:
                    logging.info("Possible format mismatch detected. Checking if file is actually a different format...")

                    # Try to detect actual format using ffprobe
                    try:
                        ffprobe_cmd = ffmpeg_cmd.replace('ffmpeg', 'ffprobe') if 'ffmpeg' in ffmpeg_cmd else 'ffprobe'
                        probe_result = subprocess.run(
                            [ffprobe_cmd, "-v", "error", "-show_entries", "format=format_name",
                             "-of", "default=noprint_wrappers=1:nokey=1", str(input_path)],
                            capture_output=True,
                            text=True,
                            timeout=5
                        )

                        if probe_result.returncode == 0 and probe_result.stdout.strip():
                            detected_format = probe_result.stdout.strip()
                            logging.info(f"Detected actual format: {detected_format}")

                            # If it's actually m4a/mp4 audio, we can try to process it
                            if 'mp4' in detected_format or 'm4a' in detected_format or 'mov' in detected_format:
                                logging.info("File appears to be MP4/M4A format despite .mp3 extension. Will attempt conversion with format override.")
                                # Don't raise error, let FFmpeg try with proper format detection
                            else:
                                raise ConversionError(f"Audio file '{input_path.name}' is corrupted or invalid: {validation_msg}")
                        else:
                            raise ConversionError(f"Audio file '{input_path.name}' is corrupted or invalid: {validation_msg}")

                    except subprocess.TimeoutExpired:
                        logging.error("Format detection timed out")
                        raise ConversionError(f"Audio file '{input_path.name}' is corrupted or invalid: {validation_msg}")
                    except Exception as e:
                        logging.error(f"Error during format detection: {e}")
                        raise ConversionError(f"Audio file '{input_path.name}' is corrupted or invalid: {validation_msg}")
                else:
                    raise ConversionError(f"Audio file '{input_path.name}' is corrupted or invalid: {validation_msg}")
            # For other issues, continue anyway as FFmpeg might still handle it

    logging.info(f"Starting conversion to WAV: '{input_path.name}' -> '{out_path.name}'")

    # Enhanced command with better detection parameters
    # Don't force format based on extension - let FFmpeg auto-detect
    command = [
        ffmpeg_cmd,
        "-analyzeduration", "10M",    # Increase analysis duration for better format detection
        "-probesize", "50M",          # Increase probe size for difficult files
        "-i", str(input_path),        # Input file - FFmpeg will auto-detect format
        "-ss", str(offset),           # Use offset if needed (e.g., "00:00:10" or 10)
    ]
    if duration_seconds is not None:
        command.extend(["-t", str(duration_seconds)])
    command.extend([
        "-ar", "16000",               # Audio sample rate (good for Whisper)
        "-ac", "1",                   # Mono audio channel (good for Whisper)
        "-c:a", "pcm_s16le",          # Standard WAV audio codec
        "-y",                         # Overwrite output file without asking
        str(out_path)
    ])

    try:
        # Execute ffmpeg command with enhanced parameters
        result = subprocess.run(
            command,
            stdin=subprocess.DEVNULL, # Prevent ffmpeg from waiting for stdin
            capture_output=True,      # Capture stdout and stderr
            text=True,                # Decode output as text
            check=False               # Don't raise exception on non-zero exit code automatically
        )

        # Check result
        if result.returncode != 0:
            # Log initial failure
            logging.warning(f"Initial conversion failed for '{input_path.name}', trying with more aggressive parameters")

            # Try with more lenient settings as fallback
            fallback_command = [
                ffmpeg_cmd,
                "-analyzeduration", "100M",   # Much higher analysis duration
                "-probesize", "100M",         # Much higher probe size
                "-err_detect", "ignore_err",  # Ignore errors and continue
                "-i", str(input_path),        # Let FFmpeg auto-detect format
                "-ss", str(offset),
            ]
            if duration_seconds is not None:
                fallback_command.extend(["-t", str(duration_seconds)])
            fallback_command.extend([
                "-ar", "16000",
                "-ac", "1",
                "-c:a", "pcm_s16le",
                "-y",
                str(out_path)
            ])

            # Don't force format - let FFmpeg figure it out
            # The file extension might be wrong (e.g., m4a file with .mp3 extension)

            fallback_result = subprocess.run(
                fallback_command,
                stdin=subprocess.DEVNULL,
                capture_output=True,
                text=True,
                check=False
            )

            if fallback_result.returncode == 0:
                logging.info(f"Fallback conversion succeeded for '{input_path.name}'")
                result = fallback_result
                # Log any warnings from the fallback attempt
                if fallback_result.stderr:
                    logging.debug(f"Fallback conversion warnings: {fallback_result.stderr[:500]}")
            else:
                # Both attempts failed, use original error for reporting
                error_details = result.stderr or result.stdout or "No output captured"
                # Clean up potentially corrupted output file
                if out_path.exists():
                    try: out_path.unlink()
                    except OSError: pass
                raise ConversionError(f"FFmpeg conversion failed (code {result.returncode}) for '{input_path.name}'. Error: {error_details.strip()}")
        else:
            logging.info(f"Conversion to WAV completed successfully: {out_path}")
            if result.stderr: # Log warnings even on success
                logging.warning(f"FFmpeg potential warnings for '{input_path.name}': {result.stderr.strip()}")
            log_counter("convert_to_wav_success", labels={"file_path": video_file_path})

    except ConversionError:
         # Re-raise ConversionError explicitly to ensure it's caught
         log_counter("convert_to_wav_error", labels={"file_path": video_file_path, "error": "ffmpeg_failed"})
         raise
    except Exception as e:
        # Catch other potential errors like permissions, etc.
        error_msg = f"Unexpected error during ffmpeg execution for '{input_path.name}': {e}"
        logging.error(error_msg, exc_info=True)
        log_counter("convert_to_wav_error", labels={"file_path": video_file_path, "error": str(e)})
        # Clean up potentially corrupted output file
        if out_path.exists():
             try: out_path.unlink()
             except OSError: pass
        raise ConversionError(error_msg) from e # Wrap other errors in ConversionError

    conversion_time = time.time() - start_time
    log_histogram("convert_to_wav_duration", conversion_time, labels={"file_path": video_file_path})

    gc.collect()
    return str(out_path)
#
# End of Audio Conversion Functions
##########################################################


##########################################################
#
# Transcript Handling/Processing

def format_transcription_with_timestamps(segments, keep_timestamps=True):
    """
    Formats the transcription segments with or without timestamps.
    Handles numeric seconds or pre-formatted HH:MM:SS strings.

    Parameters:
        segments (list): List of transcription segments (dicts with 'Time_Start', 'Time_End', 'Text').
        keep_timestamps (bool): Whether to include timestamps.

    Returns:
        str: Formatted transcription.
    """
    if not segments:
        return ""

    formatted_lines = []
    if keep_timestamps:
        for segment in segments:
            start = segment.get('Time_Start', 0)
            end = segment.get('Time_End', 0)
            text = segment.get('Text', '').strip()

            start_str = start
            end_str = end

            # Convert numeric seconds to HH:MM:SS if needed
            if isinstance(start, (int, float)):
                try:
                    start_str = time.strftime('%H:%M:%S', time.gmtime(float(start)))
                except (ValueError, TypeError, OSError): # Handle potential errors like large floats
                    start_str = f"{start:.2f}s" # Fallback to seconds
            if isinstance(end, (int, float)):
                try:
                    end_str = time.strftime('%H:%M:%S', time.gmtime(float(end)))
                except (ValueError, TypeError, OSError):
                    end_str = f"{end:.2f}s" # Fallback to seconds

            formatted_lines.append(f"[{start_str}-{end_str}] {text}")
    else:
        for segment in segments:
            text = segment.get('Text', '').strip()
            if text: # Avoid adding empty lines if a segment has no text
                formatted_lines.append(text)

    return "\n".join(formatted_lines)
#
# End of Transcript Handling/Processing
##########################################################


#
#
#######################################################################################################################
